diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ad1d44c..ad867b0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -11,9 +11,18 @@ /layout/ @changeroa /motion/ @changeroa /design-engineering/ @changeroa +/design-engineering/reference-profiles/ @changeroa /game-ui/ @changeroa /platform-guides/ @changeroa /consumer-reference/ @changeroa +/consumer-reference/adapters/ @changeroa +/consumer-reference/generated/ @changeroa +/consumer-reference/baselines/ @changeroa +/consumer-reference/policies/ @changeroa +/consumer-reference/fixtures/promotion/ @changeroa +/consumer-reference/schema/promotion-rfc.schema.json @changeroa +/tests/ @changeroa +/playwright.config.mjs @changeroa /GUIDE.md @changeroa /guides/ @changeroa @@ -31,3 +40,6 @@ /scripts/ @changeroa /scripts/validate-*.mjs @changeroa /scripts/test-validate-*.mjs @changeroa +/scripts/build-reference-artifacts.mjs @changeroa +/scripts/validate-reference-artifacts.mjs @changeroa +/scripts/test-reference-adapters.mjs @changeroa diff --git a/.github/workflows/evidence-freshness.yml b/.github/workflows/evidence-freshness.yml new file mode 100644 index 0000000..9cba095 --- /dev/null +++ b/.github/workflows/evidence-freshness.yml @@ -0,0 +1,62 @@ +name: Audit explicit evidence deadlines + +on: + workflow_dispatch: + schedule: + - cron: '0 5 * * 1' + +permissions: + contents: read + +concurrency: + group: evidence-freshness-${{ github.ref }} + cancel-in-progress: true + +jobs: + evidence-freshness: + name: Advisory evidence freshness audit + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Install pinned validation dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Audit declared deadlines and emit annotations + shell: bash + run: | + as_of="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + set +e + node scripts/audit-evidence-freshness.mjs \ + --record consumer-reference/baselines/calibration.json \ + --as-of "$as_of" \ + --mode advisory \ + --json > evidence-freshness-report.json + audit_status=$? + set -e + AUDIT_STATUS="$audit_status" node -e ' + const fs = require("node:fs"); + const report = JSON.parse(fs.readFileSync("evidence-freshness-report.json", "utf8")); + for (const warning of report.warnings ?? []) { + console.log(`::warning file=${warning.source_path}::${warning.code}: ${warning.message}`); + } + const status = Number(process.env.AUDIT_STATUS); + if (status !== 0 || report.ok !== true) process.exit(1); + ' + + - name: Upload freshness report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: evidence-freshness-${{ github.run_id }} + path: evidence-freshness-report.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b08d788..943f116 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,6 +1,7 @@ name: Validate StyleGallery on: + workflow_dispatch: pull_request: push: branches: @@ -9,19 +10,29 @@ on: permissions: contents: read +concurrency: + group: stylegallery-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: validate: name: Validate StyleGallery contracts runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: false - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 + - name: Install pinned validation dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Syntax and whitespace run: | node -c scripts/generate-patterns.mjs @@ -36,6 +47,15 @@ jobs: node -c scripts/test-validate-ia.mjs node -c scripts/validate-vocabulary.mjs node -c scripts/test-validate-vocabulary.mjs + node -c scripts/component-state-workflow-contract.mjs + node -c scripts/consumer-evidence-governance-contract.mjs + node -c scripts/promotion-governance-contract.mjs + node -c scripts/promotion-fixture-inventory.mjs + node -c scripts/promotion-boundary-test-contract.mjs + node -c scripts/governed-profile-registry.mjs + node -c scripts/promotion-attestation-contract.mjs + node -c scripts/governance-test-fixture.mjs + node -c scripts/workflow-action-contract.mjs node -c scripts/validate-governance.mjs node -c scripts/test-validate-governance.mjs node -c scripts/validate-domains.mjs @@ -48,11 +68,86 @@ jobs: node -c scripts/test-validate-webpage-workflow.mjs node -c scripts/validate-consumer-reference.mjs node -c scripts/test-validate-consumer-reference.mjs + node -c scripts/test-json-cli-output.mjs + node -c scripts/consumer-reference-case-runner.mjs + node -c scripts/validate-promotion-rfc.mjs + node -c scripts/test-validate-promotion-rfc.mjs + node -c scripts/json-schema-formats.mjs + node -c scripts/consumer-conformance-contract.mjs + node -c scripts/validate-consumer-conformance.mjs + node -c scripts/test-validate-consumer-conformance.mjs + node -c consumer-reference/fixtures/consumer-conformance/e2e-fixture.mjs + node -c scripts/page-evidence-contract.mjs + node -c scripts/page-evidence-fixture.mjs + node -c scripts/create-page-evidence-session.mjs + node -c scripts/finalize-page-evidence.mjs + node -c scripts/validate-page-evidence.mjs + node -c scripts/test-validate-page-evidence.mjs + node -c scripts/audit-evidence-freshness.mjs + node -c scripts/test-audit-evidence-freshness.mjs + node -c tests/fixtures/consumer-conformance-scenarios.mjs + node -c tests/helpers/render-consumer-conformance.mjs + node -c tests/consumer-conformance.spec.mjs + node -c scripts/test-consumer-conformance-sentinel.mjs + node -c consumer-reference/adapters/style-dictionary.config.mjs + node -c scripts/reference-token-contract.mjs + node -c scripts/reference-artifact-path-contract.mjs + node -c scripts/build-reference-artifacts.mjs + node -c scripts/validate-reference-artifacts.mjs + node -c scripts/test-reference-adapters.mjs + node -c playwright.config.mjs + node -c tests/helpers/render-consumer-reference.mjs + node -c tests/consumer-reference-sentinels.spec.mjs + node -c scripts/baseline-contract.mjs + node -c scripts/strict-json.mjs + node -c scripts/calibration-raw-contract.mjs + node -c scripts/validate-baseline-manifest.mjs + node -c scripts/test-validate-baseline-manifest.mjs + node -c scripts/baseline-schema-parity.mjs + node -c scripts/summarize-sentinel-calibration.mjs + node -c scripts/test-summarize-sentinel-calibration.mjs + node -c scripts/validate-renderer-purity.mjs + node -c scripts/test-renderer-purity.mjs + node -c scripts/test-consumer-reference-sentinel.mjs + node -c scripts/component-state-contract.mjs + node -c scripts/component-state-semantics.mjs + node -c scripts/capture-session-contract.mjs + node -c scripts/create-component-state-session.mjs + node -c scripts/profile-record-contract.mjs + node -c scripts/evidence-artifact-contract.mjs + node -c scripts/finalize-component-state-evidence.mjs + node -c scripts/validate-component-state.mjs + node -c scripts/test-validate-component-state.mjs + node -c scripts/test-validate-component-state-artifacts.mjs + node -c scripts/test-component-state-source-contract.mjs + node -c scripts/test-component-state-sentinel.mjs + node -c scripts/generate-consumer-reference-evidence.mjs + node -c scripts/test-generate-consumer-reference-evidence.mjs + node -c tests/helpers/render-component-state.mjs + node -c tests/component-state-evidence.spec.mjs + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/baseline-manifest.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/calibration-record.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/component-state.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/capture-session.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/dom-evidence.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/ax-evidence.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/visual-evidence.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/fixture-manifest.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/evidence-record.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/runtime-evidence-manifest.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/promotion-rfc.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/consumer-conformance-record.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/page-evidence-session.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/page-evidence-manifest.schema.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/policies/shared-experimental.json"))' + node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/fixtures/promotion/manifest.json"))' git diff --check - name: Generated artifacts are current run: | node scripts/generate-patterns.mjs + node scripts/build-reference-artifacts.mjs --adapter style-dictionary --fail-on-warning --json + node scripts/generate-consumer-reference-evidence.mjs --json git diff --exit-code - name: OKF structure validation @@ -102,6 +197,42 @@ jobs: node scripts/validate-consumer-reference.mjs --json node scripts/test-validate-consumer-reference.mjs --json + - name: JSON CLI output integrity + run: node scripts/test-json-cli-output.mjs + + - name: Shared promotion policy contract + run: | + node scripts/validate-promotion-rfc.mjs --json + node scripts/test-validate-promotion-rfc.mjs --json + + - name: Consumer migration and page-evidence contracts + run: | + node scripts/test-validate-consumer-conformance.mjs --case valid-runtime-proof --json + node scripts/test-validate-consumer-conformance.mjs --json + node scripts/test-validate-page-evidence.mjs --json + node scripts/test-audit-evidence-freshness.mjs --json + + - name: Portable token artifact integrity + run: | + node scripts/validate-reference-artifacts.mjs --manifest consumer-reference/generated/manifest.json --json + node scripts/test-reference-adapters.mjs --json + + - name: Compound component state and evidence integrity + run: | + node scripts/test-component-state-source-contract.mjs + node scripts/validate-component-state.mjs --json + node scripts/test-validate-component-state.mjs --json + node scripts/generate-consumer-reference-evidence.mjs --check --json + node scripts/test-generate-consumer-reference-evidence.mjs --json + + - name: Chromium baseline manifest integrity + run: | + node scripts/validate-baseline-manifest.mjs --json + node scripts/test-validate-baseline-manifest.mjs --json + node scripts/test-summarize-sentinel-calibration.mjs + node scripts/validate-renderer-purity.mjs --json + node scripts/test-renderer-purity.mjs + - name: Webpage workflow check run: | node scripts/validate-webpage-workflow.mjs --json @@ -112,3 +243,289 @@ jobs: - name: Pattern adversarial fixtures run: node scripts/test-validate-patterns.mjs --json + + consumer-conformance: + name: Consumer conformance Chromium matrix + runs-on: ubuntu-24.04 + container: + image: mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a + options: --platform linux/amd64 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Install exact conformance dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Run deterministic browser matrix + run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line + + - name: Run browser matrix semantic negative controls + run: node scripts/test-consumer-conformance-sentinel.mjs --json + + consumer-page-evidence: + name: Consumer page-evidence raster capture (nonblocking) + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + continue-on-error: true + env: + CONSUMER_CONFORMANCE_CAPTURE_DIR: .tmp/consumer-page-evidence + PAGE_EVIDENCE_ARTIFACT_ROOT: ${{ github.workspace }}/.tmp/consumer-page-evidence-consumer/evidence/session + PAGE_EVIDENCE_CASE_ID: state-w1024-focus + PAGE_EVIDENCE_CONSUMER_ROOT: ${{ github.workspace }}/.tmp/consumer-page-evidence-consumer + PAGE_EVIDENCE_PACKET_DIR: ${{ github.workspace }}/.tmp/consumer-page-evidence-packet + PAGE_EVIDENCE_RECORD: ${{ github.workspace }}/.tmp/consumer-page-evidence-consumer/records/consumer-conformance.json + PAGE_EVIDENCE_RUNNER_RESULT: runner/responsive-layout.json + PAGE_EVIDENCE_SCENARIO_ID: responsive-layout + container: + image: mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a + options: --platform linux/amd64 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup Node 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Install exact capture dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Prepare synthetic consumer sources + run: >- + node scripts/run-consumer-page-evidence-ci.mjs prepare + --consumer-root "$PAGE_EVIDENCE_CONSUMER_ROOT" + --record "$PAGE_EVIDENCE_RECORD" + --artifact-root "$PAGE_EVIDENCE_ARTIFACT_ROOT" + + - name: Create source-bound page-evidence session + run: | + consumer_sha=$(git -C "$PAGE_EVIDENCE_CONSUMER_ROOT" rev-parse HEAD) + env -u GITHUB_REPOSITORY -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \ + GITHUB_SHA="$consumer_sha" node scripts/create-page-evidence-session.mjs \ + --root "$PAGE_EVIDENCE_CONSUMER_ROOT" \ + --record "$PAGE_EVIDENCE_RECORD" \ + --artifact-root "$PAGE_EVIDENCE_ARTIFACT_ROOT" \ + --json + + - name: Verify synthetic page-evidence identity + run: >- + node scripts/run-consumer-page-evidence-ci.mjs verify-session + --consumer-root "$PAGE_EVIDENCE_CONSUMER_ROOT" + --artifact-root "$PAGE_EVIDENCE_ARTIFACT_ROOT" + + - name: Capture the selected consumer conformance raster + run: | + cd "$PAGE_EVIDENCE_CONSUMER_ROOT" + CONSUMER_CONFORMANCE_CAPTURE_DIR= npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line --grep "state-w1024-focus" + + - name: Finalize the page-evidence session + run: | + review_by=$(node -e 'process.stdout.write(new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString())') + env -u GITHUB_REPOSITORY -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u GITHUB_SHA \ + node scripts/finalize-page-evidence.mjs \ + --root "$PAGE_EVIDENCE_CONSUMER_ROOT" \ + --artifact-root "$PAGE_EVIDENCE_ARTIFACT_ROOT" \ + --runner-result "$PAGE_EVIDENCE_RUNNER_RESULT" \ + --review-by "$review_by" \ + --json + + - name: Validate the completed page-evidence packet + run: | + env -u GITHUB_REPOSITORY -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u GITHUB_SHA \ + node scripts/validate-page-evidence.mjs \ + --root "$PAGE_EVIDENCE_CONSUMER_ROOT" \ + --artifact-root "$PAGE_EVIDENCE_ARTIFACT_ROOT" \ + --json + + - name: Package only validated page evidence + run: >- + node scripts/run-consumer-page-evidence-ci.mjs package + --consumer-root "$PAGE_EVIDENCE_CONSUMER_ROOT" + --artifact-root "$PAGE_EVIDENCE_ARTIFACT_ROOT" + --output "$PAGE_EVIDENCE_PACKET_DIR" + + - name: Upload optional consumer page evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: consumer-page-evidence-${{ github.run_id }}-${{ github.sha }} + path: ${{ env.PAGE_EVIDENCE_PACKET_DIR }}/ + if-no-files-found: warn + retention-days: 14 + + - name: Clean up synthetic consumer + if: always() + run: >- + node scripts/run-consumer-page-evidence-ci.mjs cleanup + --consumer-root "$PAGE_EVIDENCE_CONSUMER_ROOT" + + chromium-sentinel: + name: Chromium reference sentinel (nonblocking) + runs-on: ubuntu-24.04 + continue-on-error: true + container: + image: mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a + options: --platform linux/amd64 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup Node 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Install exact sentinel dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Run ordinary immutable Chromium sentinel + run: npm run test:sentinel -- --reporter=line + + - name: Run Chromium sentinel semantic negative controls + run: node scripts/test-consumer-reference-sentinel.mjs + + component-state-evidence: + name: Chromium component-state evidence (nonblocking) + runs-on: ubuntu-24.04 + continue-on-error: true + env: + STATE_EVIDENCE_ROOT: .tmp/consumer-reference-state + SENTINEL_CONTAINER_IMAGE: mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a + container: + image: mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a + options: --platform linux/amd64 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup Node 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Install exact component-state dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Run component-state artifact and session adversarial harness + run: node scripts/test-validate-component-state-artifacts.mjs + + - name: Capture and validate closed component-state evidence + shell: bash + run: | + set -o pipefail + node scripts/create-component-state-session.mjs \ + --output "$STATE_EVIDENCE_ROOT/capture-session.json" \ + --json + STATE_ARTIFACT_DIR="$STATE_EVIDENCE_ROOT/runtime" \ + STATE_SESSION_RECEIPT="$STATE_EVIDENCE_ROOT/capture-session.json" \ + npm run test:component-state:runtime -- --reporter=line + node scripts/finalize-component-state-evidence.mjs \ + --artifact-root "$STATE_EVIDENCE_ROOT" \ + --output "$STATE_EVIDENCE_ROOT/runtime-manifest.json" \ + --json + node scripts/validate-component-state.mjs \ + --artifact-root "$STATE_EVIDENCE_ROOT" \ + --runtime-manifest "$STATE_EVIDENCE_ROOT/runtime-manifest.json" \ + --json + + - name: Run component-state runtime negative controls + run: npm run test:component-state:runtime-negative + + - name: Upload claim-scoped component-state evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: consumer-reference-state-${{ github.run_id }}-${{ github.sha }} + path: ${{ env.STATE_EVIDENCE_ROOT }}/ + if-no-files-found: error + retention-days: 14 + + chromium-calibration: + name: Chromium 20-run calibration (nonblocking) + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + continue-on-error: true + container: + image: mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a + options: --platform linux/amd64 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Setup Node 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Install exact calibration dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Capture 20 unique raw calibration runs + shell: bash + env: + SENTINEL_CONTAINER_IMAGE: mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a + run: | + set +e + failed=0 + mkdir -p calibration-artifacts + artifact_name="chromium-sentinel-calibration-${GITHUB_RUN_ID}-${GITHUB_SHA}" + checkout_sha="$(git -c safe.directory="$GITHUB_WORKSPACE" rev-parse HEAD)" + for run in $(seq 1 20); do + run_dir="calibration-artifacts/run-$(printf '%02d' "$run")" + mkdir -p "$run_dir" + SENTINEL_ARTIFACT_DIR="$run_dir" SENTINEL_CALIBRATION_RUN="$run" \ + npx playwright test tests/consumer-reference-sentinels.spec.mjs \ + --project=chromium --grep "canonical consumer reference" --reporter=json \ + > "$run_dir/playwright.json" 2> "$run_dir/playwright.stderr" + test_exit=$? + if [ "$test_exit" -ne 0 ]; then failed=1; fi + printf '{"schema_version":"1.0","run":%s,"exit_code":%s}\n' "$run" "$test_exit" > "$run_dir/exit.json" + done + validation_output="${RUNNER_TEMP}/chromium-calibration-validation-${GITHUB_RUN_ID}.json" + node scripts/summarize-sentinel-calibration.mjs \ + --input calibration-artifacts \ + --output calibration-artifacts/calibration.json \ + --repository "changeroa/StyleGallery" \ + --execution-repository "$GITHUB_REPOSITORY" \ + --workflow ".github/workflows/validate.yml" \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" \ + --sha "$GITHUB_SHA" \ + --checkout-sha "$checkout_sha" \ + --head-sha "${{ github.event.pull_request.head.sha || github.sha }}" \ + --artifact-name "$artifact_name" \ + --json > "$validation_output" + summary_exit=$? + mv "$validation_output" calibration-artifacts/calibration-validation.json + printf '{"repository":"%s","execution_repository":"%s","run_id":"%s","sha":"%s","artifact_name":"%s","platform":"linux/amd64","architecture":"amd64","container_image":"%s"}\n' \ + "changeroa/StyleGallery" "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" "$GITHUB_SHA" "$artifact_name" "$SENTINEL_CONTAINER_IMAGE" \ + > calibration-artifacts/workflow-metadata.json + if [ "$summary_exit" -ne 0 ]; then failed=1; fi + exit "$failed" + + - name: Upload raw Chromium calibration evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: chromium-sentinel-calibration-${{ github.run_id }}-${{ github.sha }} + path: calibration-artifacts/ + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc25d7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +playwright-report/ +test-results/ +blob-report/ diff --git a/DOMAINS.md b/DOMAINS.md index 838dc5e..921800d 100644 --- a/DOMAINS.md +++ b/DOMAINS.md @@ -26,7 +26,7 @@ This manifest is the source of truth for top-level StyleGallery domains. A domai | --- | --- | --- | | Layout | `layout/index.md` | Existing `GUIDE.md`, Layout-specific `guides/*.md`, and `recipes/*.md`; generated `patterns/**/*.md` and `CATALOG.md` remain at current paths. Shared `quality/**/*.md` infrastructure governs every domain without becoming a Layout leaf. | | Motion | `motion/index.md` | `motion/vocabulary.md`, `motion/review-workflow.md`, `motion/practice-reference.md` | -| Design Engineering | `design-engineering/index.md` | `design-engineering/interface-craft.md` | +| Design Engineering | `design-engineering/index.md` | `design-engineering/interface-craft.md`, `design-engineering/consumer-migration-readiness.md`, `design-engineering/reference-profiles/index.md`, `design-engineering/reference-profiles/governed-local/index.md`, `design-engineering/reference-profiles/external-adaptation/index.md` | | Game UI | `game-ui/index.md` | `game-ui/classification.md`, `game-ui/screen-hierarchy.md`, `game-ui/reference-record.md`, `game-ui/unity/architecture.md`, `game-ui/unity/ui-systems.md`, `game-ui/unity/cli-loop.md`, `game-ui/unity/repository-map.md`, `game-ui/unity/org-wiki.md` | | Platform Guides | `platform-guides/index.md` | `platform-guides/apple-interaction.md` | @@ -45,10 +45,18 @@ The initial five domain leaves are independent method rewrites inspired by [emil - If recognizable upstream expression is added later, preserve the full upstream MIT notice and record the copied material separately before merge. - Tracked repository documents must not depend on `.omo/`; stable upstream blob links and tracked repository contracts carry contributor-facing provenance. +`design-engineering/consumer-migration-readiness.md` is the separately declared StyleGallery-local leaf. It uses `provenance_kind: local`, carries no external source fields, and remains experimental. Its presence does not change the external-adaptation inventory or make its consumer-local method universal policy. + ## Promotion And Staleness External adaptations begin `experimental`. Promotion to `stable` requires a demonstrated reader or review task, evidence that the document improves that task, a named review owner, and no unresolved provenance or platform-version debt. Remove or revise a page when its source revision changes materially, a platform claim becomes stale, a local quality gate contradicts it, or a reader test shows the route is misleading. +### Consumer Reference Promotion + +Consumer-reference promotion does not add a domain. The StyleGallery-local `>=2` independent-consumer gateway applies only to consumer-local → shared-experimental invariant eligibility. Editorial and terminal are related examples in one fixture set, so neither counts as an independent consumer. Shared stable has no numeric adoption threshold. + +Normative correctness may waive adoption count only; regression evidence, invariant scope, owner and support capacity, compatibility, migration, deprecation, rollback, and provenance duties remain required. A failed stable contract is restricted, maintained, rolled back, or deprecated with migration, never silently relabeled experimental. Promotion records are JSON-only; the committed examples are synthetic, `example_only`, deferred, and contain zero adopter attestations. + Consumer-reference maturity is governed independently from artifact mode. Stable records cannot have ended support, and related repository fixtures are not independent adoption evidence. ## IA Navigation diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 3e46291..7172961 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -3,6 +3,7 @@ type: Governance Reference title: Governance, Lifecycle, And Docs-As-Code description: Source-of-truth, generated artifact, domain, lifecycle, ownership, and stale-content policy for StyleGallery. scheduled_stale_audit: deferred +scheduled_evidence_audit: active_advisory --- # Governance, Lifecycle, And Docs-As-Code @@ -21,7 +22,16 @@ Use this file before editing repository documentation. It names which file is au | Planning guides | `GUIDE.md`, `guides/*.md` | Manual | None | `stable` | Workflow changes, route changes, source-lineage changes, or broken guide links. | `scripts/validate-okf.mjs`, `scripts/validate-links.mjs`, `scripts/validate-ia.mjs` | Planning-doc owner | | Layout recipes | `recipes/*.md` | Manual | None | `stable` | Pattern-stack changes, route changes, or broken recipe links. | `scripts/validate-okf.mjs`, `scripts/validate-links.mjs`, `scripts/validate-ia.mjs` | Recipe owner | | Quality gates and evidence | `quality/**/*.md` | Manual | None | `stable` | Claim-boundary changes, evidence-family changes, or broken quality links. | `scripts/validate-okf.mjs`, `scripts/validate-links.mjs`, `scripts/validate-ia.mjs` | Quality owner | -| Consumer reference contract | `consumer-reference/contract.md`, `consumer-reference/schema/*.json` | Manual | None | `stable` contract with related fixtures | Handoff shape, path boundary, lifecycle, ownership, or dependency-direction changes. | `scripts/validate-consumer-reference.mjs`, `scripts/test-validate-consumer-reference.mjs` | Repository governance owner with Validation owner | +| Consumer reference contract | `consumer-reference/contract.md`, `consumer-reference/schema/item.schema.json` | Manual | None | `stable` contract with related fixtures | Handoff shape, path boundary, lifecycle, ownership, or dependency-direction changes. | `scripts/validate-consumer-reference.mjs`, `scripts/test-validate-consumer-reference.mjs` | Repository governance owner with Validation owner | +| Portable token source | `consumer-reference/fixtures/token-portability/valid-reference.json`, `consumer-reference/schema/portable-tokens.schema.json` | `scripts/build-reference-artifacts.mjs` with Style Dictionary `5.5.0` | `consumer-reference/generated/tokens.css`, `consumer-reference/generated/manifest.json` | `stable` restricted adapter contract, `generated` output | Allowed token shape, adapter/version pin, source token count, warning, declaration, or content hash changes. | `scripts/validate-reference-artifacts.mjs`, `scripts/test-reference-adapters.mjs` | Repository governance owner with Validation owner | +| Governed local reference profiles | `design-engineering/reference-profiles/governed-local/editorial/profile.json`, `design-engineering/reference-profiles/governed-local/editorial/tokens.dtcg.json`, `design-engineering/reference-profiles/governed-local/editorial/local-foundations.json`, `design-engineering/reference-profiles/governed-local/terminal/profile.json`, `design-engineering/reference-profiles/governed-local/terminal/tokens.dtcg.json`, `design-engineering/reference-profiles/governed-local/terminal/local-foundations.json` | Manual | None | `experimental`, `example_only`, non-default related fixtures | Layout revision, identity values, UA/reset assumptions, explicit selection, or fixture relationship changes. | `scripts/validate-consumer-reference.mjs`, `scripts/test-validate-consumer-reference.mjs` | Design Engineering owner with Validation owner | +| Component-state evidence matrices | Each profile's declared `components/*.component.json`, `states/*.states.json`, `fixtures/*.fixture.json`, and `evidence/*.evidence.json` records | `scripts/generate-consumer-reference-evidence.mjs` | `design-engineering/reference-profiles/governed-local/editorial/generated/state-matrix.md`, `design-engineering/reference-profiles/governed-local/editorial/generated/keyboard-matrix.md`, `design-engineering/reference-profiles/governed-local/editorial/generated/evidence-coverage.md`, `design-engineering/reference-profiles/governed-local/terminal/generated/state-matrix.md`, `design-engineering/reference-profiles/governed-local/terminal/generated/keyboard-matrix.md`, `design-engineering/reference-profiles/governed-local/terminal/generated/evidence-coverage.md` | `generated` output from `experimental` canonical records | Declared record paths, capture-session identity, scenario/mode/channel counts, claim boundary, generated escaping, or generator output changes. | `scripts/validate-component-state.mjs`, `scripts/test-validate-component-state.mjs`, `scripts/test-validate-component-state-artifacts.mjs`, `scripts/test-generate-consumer-reference-evidence.mjs` | Design Engineering owner with Validation owner | +| Consumer migration conformance | `consumer-reference/schema/consumer-conformance-record.schema.json`, `design-engineering/consumer-migration-readiness.md` | Manual | Consumer-owned conformance records | `experimental` method | Method, dimension, scenario, mapping, or ownership changes. | `scripts/validate-consumer-conformance.mjs`, `scripts/test-validate-consumer-conformance.mjs` | Design Engineering owner with Validation owner | +| Consumer page-evidence lifecycle | `consumer-reference/schema/page-evidence-session.schema.json`, `consumer-reference/schema/page-evidence-manifest.schema.json` | Browser/session tooling | Consumer-owned session, runner, manifest, and raster artifacts | `experimental` evidence protocol | Source, revision, session, run, scenario, review deadline, or artifact integrity changes. | `scripts/test-validate-page-evidence.mjs`, deterministic Chromium consumer-conformance job | Validation owner with consumer owner | +| Explicit evidence freshness schedule | Evidence records that declare `expires_at` or `review_by` | `.github/workflows/evidence-freshness.yml` | Advisory JSON audit artifact | `stable` narrow schedule | Explicit deadline, auditor, schedule, or inventory changes. | `scripts/audit-evidence-freshness.mjs`, `scripts/test-audit-evidence-freshness.mjs` | Validation owner | +| Deterministic consumer browser conformance | `tests/consumer-conformance.spec.mjs`, `tests/fixtures/consumer-conformance-scenarios.mjs`, `tests/helpers/render-consumer-conformance.mjs` | Playwright `1.61.0` in the digest-pinned `linux/amd64` image | Optional page-evidence raster artifact | `experimental` contract with blocking semantic gate and nonblocking raster capture | Viewport, container, content, state, overlay, page-scale, focus, overflow, contrast, semantics, or runtime-error changes. | Playwright matrix and `scripts/test-consumer-conformance-sentinel.mjs` | Validation owner with Design Engineering owner | +| Shared promotion policy | `consumer-reference/policies/shared-experimental.json`, `consumer-reference/schema/promotion-rfc.schema.json` | Manual | None; promotion fixtures are synthetic examples | `stable` policy with `example_only`, deferred fixtures | Gateway scope, independence basis, evidence, ownership, support, compatibility, migration, deprecation, rollback, provenance, or decision changes. | `scripts/validate-promotion-rfc.mjs`, `scripts/test-validate-promotion-rfc.mjs` | Repository governance owner with actual consumer, support, migration, rollback, and Validation owners | +| Proposed Chromium sentinel | `tests/helpers/render-consumer-reference.mjs`, `consumer-reference/schema/{baseline-manifest,calibration-record}.schema.json`, `consumer-reference/baselines/*.json` | Playwright `1.61.0` in the digest-pinned `linux/amd64` image | `tests/snapshots/consumer-reference-card-grid.png`, raw GitHub Actions calibration artifact | `experimental`, nonblocking while owner approval is pending | Renderer source, exact image/platform/tool pin, baseline bytes, computed layout contract, or calibration metadata changes. | `scripts/validate-baseline-manifest.mjs`, `scripts/test-validate-baseline-manifest.mjs`, `scripts/test-summarize-sentinel-calibration.mjs`, Playwright | Repository governance owner with Validation owner | | Domain manifest and scope decision | `DOMAINS.md`, `quality/claim-records/stylegallery-multidomain-scope.md` | Manual | None | `stable` | Domain membership, repository-scope, or provenance-policy changes. | `scripts/validate-domains.mjs`, `scripts/validate-governance.mjs` | Repository governance owner | | Layout domain hub | `layout/index.md` | Manual | None | `stable` | Layout route or ownership changes. | `scripts/validate-domains.mjs`, `scripts/validate-ia.mjs` | Pattern-data owner | | Motion domain guidance | `motion/*.md` | Manual | None | `experimental` | Upstream revision, evidence boundary, or guidance changes. | `scripts/validate-domains.mjs` | Motion domain owner | @@ -31,7 +41,7 @@ Use this file before editing repository documentation. It names which file is au | Pattern data and examples | `scripts/pattern-data.mjs` | Manual data source | `patterns/**/*.md`, `patterns/**/index.md`, `patterns/index.md`, `CATALOG.md` | `generated` output from `stable` source | Source-lineage URL changes, generated drift, category changes, or pattern count changes. | `scripts/validate-patterns.mjs`, `scripts/validate-catalog.mjs`, `scripts/validate-governance.mjs` | Pattern-data owner | | Pattern generator | `scripts/generate-patterns.mjs` | Manual code source | `patterns/**/*.md`, `patterns/**/index.md`, `patterns/index.md`, `CATALOG.md` | `stable` generator, `generated` output | Generated structure changes, generated-warning changes, or generated metadata changes. | `node -c scripts/generate-patterns.mjs`, generated drift check, `scripts/validate-governance.mjs` | Pattern-data owner | | Validation scripts | `scripts/validate-*.mjs`, `scripts/test-validate-*.mjs` | Manual code source | CI validation output | `stable` | Validator scope changes, fixture changes, or CI parity changes. | `node -c`, matching fixture tests, `.github/workflows/validate.yml` | Validation owner | -| CI workflow | `.github/workflows/validate.yml` | Manual | GitHub Actions run | `stable` | Validation step changes, generated drift policy changes, or owner changes. | GitHub Actions, `scripts/validate-governance.mjs` | Repository governance owner | +| CI workflows | `.github/workflows/validate.yml`, `.github/workflows/evidence-freshness.yml` | Manual | GitHub Actions runs and audit artifacts | `stable` | Validation step, explicit-deadline schedule, generated drift policy, or owner changes. | GitHub Actions, `scripts/validate-governance.mjs` | Repository governance owner | ## Generated Artifact Policy @@ -49,6 +59,21 @@ Current generated artifacts: - `patterns/index.md` - `patterns/**/index.md` - `patterns/**/*.md` +- `consumer-reference/generated/tokens.css` +- `consumer-reference/generated/manifest.json` +- `design-engineering/reference-profiles/governed-local/editorial/generated/state-matrix.md` +- `design-engineering/reference-profiles/governed-local/editorial/generated/keyboard-matrix.md` +- `design-engineering/reference-profiles/governed-local/editorial/generated/evidence-coverage.md` +- `design-engineering/reference-profiles/governed-local/terminal/generated/state-matrix.md` +- `design-engineering/reference-profiles/governed-local/terminal/generated/keyboard-matrix.md` +- `design-engineering/reference-profiles/governed-local/terminal/generated/evidence-coverage.md` +- `tests/snapshots/consumer-reference-card-grid.png` (proposed; update only by an explicit local baseline proposal, never in CI) + +Portable token artifacts are regenerated only from the restricted fixture through the pinned adapter. Run `npm run build`; never broaden the allowed token subset to accommodate an adapter false-success, and revert the adapter with both generated files if the pin regresses. + +Component-state evidence matrices are regenerated only from the records declared by each `profile.json`. Run `node scripts/generate-consumer-reference-evidence.mjs --json`; never hand-edit any of the six matrices or substitute undeclared record paths. + +Browser state evidence begins with `scripts/create-component-state-session.mjs`. Its closed receipt binds a random nonce and session ID to the checked-out revision, branch, attempt, exact runtime pins, viewport, intended profile/scenario/channel set, and a deterministic digest of the relevant capture sources before capture. Receipt creation rejects dirty relevant sources. DOM and AX artifacts embed the receipt digest and session identity; browser-authored visual sidecars bind the same session, scenario, capture time, source digest, PNG bytes, dimensions, and hash. Finalization accepts exactly 30 channel passes over the closed 40-file runtime set. Validation uses the receipt and completed manifest interval, not a wall-clock maximum age, so an unchanged downloaded session remains verifiable later. ## Lifecycle States @@ -82,24 +107,41 @@ Consumer-reference ownership records the current truth as `owner.enforcement: "p | `guides/**`, `GUIDE.md`, `recipes/**` | Planning-doc owner | Planning flow, task routes, recipe composition boundaries. | | `quality/**` | Quality owner | Claim boundaries, executable evidence, review gates. | | `consumer-reference/**` | Repository governance owner with Validation owner | Required handoff, repository-local record safety, lifecycle separation, ownership truth, and reverse-dependency guard. | +| `consumer-reference/schema/{consumer-conformance-record,page-evidence-session,page-evidence-manifest}.schema.json`, `scripts/*consumer-conformance*.mjs`, `scripts/*page-evidence*.mjs` | Validation owner with Design Engineering owner | Closed migration dimensions, source/revision/session/run binding, artifact containment, executable scenario coverage, and consumer-owned adoption mapping. | +| `consumer-reference/policies/**`, `consumer-reference/fixtures/promotion/**`, `consumer-reference/schema/promotion-rfc.schema.json`, `scripts/*promotion-rfc.mjs` | Repository governance owner with actual consumer, support, migration, rollback, and Validation owners | Invariant-only scope, independence truth, claim-scoped evidence, compatibility, support capacity, lifecycle duties, and zero promotion/adoption overclaim. | +| `consumer-reference/adapters/**`, `consumer-reference/generated/**`, `scripts/*reference-artifacts.mjs`, `scripts/test-reference-adapters.mjs` | Repository governance owner with Validation owner | Restricted token ingress, exact adapter pin, warning handling, token/declaration counts, hashes, preserved aliases, and generated drift. | | `DOMAINS.md`, `layout/**` | Repository governance owner with Pattern-data owner | Domain routing and preservation of the stable Layout path contract. | | `motion/**` | Motion domain owner | Motion terminology, review procedure, practice classification, and evidence boundaries. | | `design-engineering/**` | Design Engineering domain owner | Separation of product heuristics from shared quality gates. | +| `design-engineering/reference-profiles/**` | Design Engineering owner with Validation owner | Profile-local identity and values, pinned Layout provenance, explicit non-default selection, and related-fixture truth. | +| `tests/**`, `playwright.config.mjs`, `consumer-reference/baselines/**`, `scripts/*baseline*.mjs`, `scripts/*sentinel*.mjs`, `scripts/*renderer-purity.mjs` | Repository governance owner with Validation owner | Pure rendering, computed semantics before screenshots, immutable browser pins, required consumer-conformance semantics, optional raster capture, calibration cardinality, and pending owner approval. | | `game-ui/**` | Game UI domain owner | Player-task classification, hierarchy responsibility, reference records, engine-specific subtrees, and cross-engine boundaries. | | `platform-guides/**` | Platform Guides domain owner | Platform/source/version limits, comparison boundaries, and stale review. | -| `scripts/validate-*.mjs`, `scripts/test-validate-*.mjs`, `.github/workflows/validate.yml` | Validation owner | Validator scope, negative fixtures, CI parity. | +| `scripts/validate-*.mjs`, `scripts/test-validate-*.mjs`, `.github/workflows/validate.yml`, `.github/workflows/evidence-freshness.yml` | Validation owner | Validator scope, negative fixtures, blocking/advisory boundaries, CI parity. | + +## Shared Promotion Governance + +The StyleGallery-local `>=2` independent-consumer gateway applies only to consumer-local → shared-experimental invariant eligibility. It is neither a universal rule nor a stable threshold. Shared stable has no numeric adoption threshold; it requires claim-scoped evidence, active owner and support capacity, compatibility review, known-issue and exception visibility, migration and deprecation readiness, rollback triggers, resolved provenance debt, and an explicit scope decision. + +Normative accessibility, security, standards, or correctness fixes may waive adoption count only. They never waive regression proof, scope control, owner acceptance, support capacity, compatibility, migration, deprecation, or rollback. Palette, typography, imagery, motion character, and component skin remain consumer-local even when repeated. + +The committed RFC fixtures are synthetic `example_only` proposals with `decision: "deferred"`; they contain zero durable adopter attestations and record no promotion. Editorial and terminal remain related fixtures in one set and never count as independent consumers. Current review truth remains `review_independence: "single_account"`; booleans and multiple role labels do not create independent review. + +If evidence fails, restrict, maintain, or roll back the candidate. A consumed stable contract may be deprecated only with a named migration; it must never be silently relabeled shared-experimental. The canonical JSON policy and RFC records own these decisions; no YAML promotion record is valid. ## Staleness Control -Decision: no scheduled stale-content workflow yet. +Decision: run a weekly advisory audit only for evidence records that already declare `expires_at` or `review_by`. + +The independent `.github/workflows/evidence-freshness.yml` schedule runs Monday at 05:00 UTC and also supports manual dispatch. It currently inventories the committed Chromium calibration record; consumer-owned page-evidence manifests enter the same audit by passing their explicit record path to `scripts/audit-evidence-freshness.mjs`. -Reason: the current repository is small, CI already checks local links and generated drift on pull requests and pushes, and a scheduled workflow would add notification noise without a clear owner rotation. +No repository-wide maximum age or inferred time-to-live applies. Evidence without an explicit deadline remains outside this schedule. An expired `expires_at` or due `review_by` emits an advisory annotation and a JSON report without failing solely because the date passed. Malformed records and auditor failures remain blocking even though due-date findings are advisory. Audit trigger: - Run `node scripts/validate-links.mjs --json` and `node scripts/validate-governance.mjs --json` when external source lineage, generated policy, root navigation, or validation ownership changes. - Run `node scripts/validate-domains.mjs --json` and `node scripts/test-validate-domains.mjs` when domain membership, scope boundaries, source paths, source revisions, platform context, or promotion state changes. -- Reconsider a scheduled stale audit when source-lineage URLs exceed 50 entries, when an external-link failure escapes a pull request, or when ownership changes away from a single maintainer. +- Run `node scripts/test-audit-evidence-freshness.mjs --json` when `expires_at`, `review_by`, record inventory, advisory classification, or the scheduled workflow changes. ## Required Verification @@ -113,6 +155,10 @@ node scripts/validate-domains.mjs --json node scripts/test-validate-domains.mjs node scripts/validate-consumer-reference.mjs --json node scripts/test-validate-consumer-reference.mjs --json +node scripts/test-validate-consumer-conformance.mjs --case valid-runtime-proof --json +node scripts/test-validate-consumer-conformance.mjs --json +node scripts/test-validate-page-evidence.mjs --json +node scripts/test-audit-evidence-freshness.mjs --json ``` For generated pattern or catalog changes, also run: @@ -123,3 +169,53 @@ git diff --exit-code -- CATALOG.md patterns node scripts/validate-patterns.mjs --min-count 46 --json node scripts/validate-catalog.mjs --json ``` + +For portable token source, adapter, or generated artifact changes, run: + +```sh +npm ci --ignore-scripts --no-audit --no-fund +node scripts/build-reference-artifacts.mjs --adapter style-dictionary --fail-on-warning --json +node scripts/validate-reference-artifacts.mjs --manifest consumer-reference/generated/manifest.json --json +node scripts/test-reference-adapters.mjs --json +git diff --exit-code -- consumer-reference/generated +``` + +For the proposed Chromium sentinel, also run: + +```sh +npm run test:sentinel +node scripts/test-consumer-reference-sentinel.mjs +node scripts/validate-baseline-manifest.mjs --json +node scripts/test-validate-baseline-manifest.mjs --json +node scripts/test-summarize-sentinel-calibration.mjs +node scripts/validate-renderer-purity.mjs --json +node scripts/test-renderer-purity.mjs +``` + +For capture-session-bound component-state evidence, also run: + +```sh +STATE_EVIDENCE_ROOT=".tmp/consumer-reference-state" +mkdir -p "$STATE_EVIDENCE_ROOT" +node scripts/create-component-state-session.mjs --output "$STATE_EVIDENCE_ROOT/capture-session.json" --json +STATE_SESSION_RECEIPT="$STATE_EVIDENCE_ROOT/capture-session.json" \ + STATE_ARTIFACT_DIR="$STATE_EVIDENCE_ROOT/runtime" \ + npm run test:component-state:runtime -- --reporter=line +node scripts/finalize-component-state-evidence.mjs --artifact-root "$STATE_EVIDENCE_ROOT" --json +node scripts/validate-component-state.mjs \ + --artifact-root "$STATE_EVIDENCE_ROOT" \ + --runtime-manifest "$STATE_EVIDENCE_ROOT/runtime-manifest.json" \ + --json +npm run test:component-state:runtime-negative +``` + +For shared promotion governance, also run: + +```sh +node scripts/validate-promotion-rfc.mjs --json +node scripts/test-validate-promotion-rfc.mjs --json +``` + +Do not pass `--update-snapshots` in CI. Calibration runs exactly 20 times on the manifest's digest-pinned `linux/amd64` container and uploads raw Playwright JSON, strict exit records, PNG, DOM, AX, metadata, and post-assertion comparison evidence. Failed or incomplete calibration still uploads its truthful raw evidence without writing a completed aggregate. It remains nonblocking until the named owner explicitly approves the baseline. + +Completed-CI repository, workflow, run ID and attempt, SHA, and artifact-name fields are workflow-recorded, self-asserted metadata, not an external attestation. The self-asserted repository field names the canonical upstream changeroa/StyleGallery; the self-asserted execution_repository field names the actual GitHub Actions repository and is limited to changeroa/StyleGallery or ark-jo/StyleGallery. The committed calibration's external_verification object records an independently checked GitHub Actions run and artifact API identity; artifact.api_digest is distinct from committed_ci.raw_evidence_sha256. Future CI aggregates remain awaiting_external_verification until their uploaded artifact API identity is independently checked. Linux/amd64 repeatability is externally verified only for committed run 29260372260; it does not establish baseline-owner approval or product suitability. Baseline-owner approval remains unclaimed until the named owner explicitly approves it. Synthetic fixtures validate rejection and acceptance behavior only; they are not authenticated provenance. diff --git a/README.md b/README.md index 1c59241..d3e0ac4 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Each common task has one primary route. Use secondary links only after the prima | `browse reusable spatial guidance` | [Layout](layout/index.md) | It preserves the existing pattern, recipe, and planning routes. | | `name or review interface motion` | [Motion](motion/index.md) | It routes to bounded terminology and review guidance. | | `review product-level interface craft` | [Design Engineering](design-engineering/index.md) | It separates practitioner heuristics from shared quality gates. | +| `compare adversarial consumer identities` | [Reference Profiles](design-engineering/reference-profiles/index.md) | It keeps non-default product values in related Design Engineering examples over one pinned Layout source. | | `classify a game interface or map it to an engine` | [Game UI](game-ui/index.md) | It separates engine-neutral roles from implementation-specific guidance. | | `compare a named platform convention` | [Platform Guides](platform-guides/index.md) | It requires platform and evidence boundaries before adaptation. | | `turn raw content into a homepage or ordinary webpage` | [Webpage Generation Workflow](guides/webpage-generation-workflow.md) | It starts with use case, content-to-layout fit, harmony, and handoff. | @@ -69,6 +70,7 @@ Each common task has one primary route. Use secondary links only after the prima | `check whether a layout or design claim is admissible` | [Quality Gates](quality/index.md) | It routes claims to gates and evidence boundaries. | | `prove repository checks and evidence coverage` | [Executable Evidence Coverage](quality/evidence/executable-evidence.md) | It maps validators, fixtures, CI commands, and their boundaries. | | `declare consumer reference applicability` | [Consumer Reference](consumer-reference/index.md) | It provides the required handoff field without moving consumer values into Layout. | +| `prove an existing consumer migration` | [Consumer Migration Readiness](design-engineering/consumer-migration-readiness.md) | It requires thirteen explicit behavior classifications, runtime proof, adoption mappings, and source-bound page evidence when applicable. | | `change generated patterns, catalog, or governance policy` | [Governance, Lifecycle, And Docs-As-Code](GOVERNANCE.md) | It identifies source files, generated artifacts, validators, lifecycle state, and review ownership. | | `run findability QA` | [Tree-Test Findability QA](quality/index.md#tree-test-findability-qa) | It tests whether task routes are discoverable, not just linked. | @@ -92,6 +94,7 @@ Each common task has one primary route. Use secondary links only after the prima - Use [Layout Pattern Catalog](CATALOG.md) when you already know the spatial problem. - Use [Quality Gates](quality/index.md) when a claim needs principle-backed evidence, visual QA boundaries, accessibility precedence, or design rationale. - Use [Consumer Reference](consumer-reference/index.md) when an implementation handoff must declare one repository-local JSON reference or a sentence explaining non-applicability. +- Use [Consumer Migration Readiness](design-engineering/consumer-migration-readiness.md) only for a migration that declares a consumer-local conformance record; ordinary handoffs keep the existing `not_applicable` path. - Use [Governance, Lifecycle, And Docs-As-Code](GOVERNANCE.md) before changing generated artifacts, validators, lifecycle state, or ownership policy. ## Layout Domain Principles diff --git a/consumer-reference/adapters/style-dictionary.config.mjs b/consumer-reference/adapters/style-dictionary.config.mjs new file mode 100644 index 0000000..d25e5ab --- /dev/null +++ b/consumer-reference/adapters/style-dictionary.config.mjs @@ -0,0 +1,36 @@ +import path from "node:path"; +import StyleDictionary from "style-dictionary"; + +export const adapter = Object.freeze({ name: "style-dictionary", version: "5.5.0" }); + +const durationTransform = "stylegallery/duration-css"; +if (!Object.hasOwn(StyleDictionary.hooks.transforms, durationTransform)) { + StyleDictionary.registerTransform({ + filter: (token) => token.$type === "duration", + name: durationTransform, + transform: (token) => `${token.$value.value}${token.$value.unit}`, + transitive: true, + type: "value", + }); +} + +export function createStyleDictionaryConfig(source, output) { + return { + log: { verbosity: "verbose", warnings: "warn" }, + platforms: { + css: { + buildPath: `${path.dirname(output)}${path.sep}`, + files: [ + { + destination: path.basename(output), + format: "css/variables", + options: { outputReferences: true }, + }, + ], + transforms: [...StyleDictionary.hooks.transformGroups.css, durationTransform], + }, + }, + source: [source], + usesDtcg: true, + }; +} diff --git a/consumer-reference/baselines/calibration.json b/consumer-reference/baselines/calibration.json new file mode 100644 index 0000000..d10b807 --- /dev/null +++ b/consumer-reference/baselines/calibration.json @@ -0,0 +1,260 @@ +{ + "baseline_owner_approval": "pending", + "committed_ci": { + "artifact_name": "chromium-sentinel-calibration-29260372260-18229be570766d3b42f5600955120bfcba690b76", + "checkout_sha": "18229be570766d3b42f5600955120bfcba690b76", + "execution_repository": "ark-jo/StyleGallery", + "head_sha": "3bfdd25ec9a2df4ca84e19541365d48842a73f59", + "raw_evidence_sha256": "1f125d5b321063b364e19283897c78c126d73eb1b3368d14686c494b1296dfab", + "repository": "changeroa/StyleGallery", + "run_attempt": "1", + "run_id": "29260372260", + "sha": "18229be570766d3b42f5600955120bfcba690b76", + "workflow": ".github/workflows/validate.yml", + "external_verification": { + "artifact": { + "api_digest": "sha256:3f11a517b447e1b5a1da17d9ee66ba2dd947fc8a0a482c447556ef00652e6074", + "expires_at": "2026-07-27T15:01:36Z", + "id": "8283099324", + "name": "chromium-sentinel-calibration-29260372260-18229be570766d3b42f5600955120bfcba690b76", + "size_in_bytes": 70310 + }, + "repository_relationship": { + "execution_is_fork": true, + "parent": "changeroa/StyleGallery", + "source": "changeroa/StyleGallery" + }, + "source": "https://github.com/ark-jo/StyleGallery/actions/runs/29260372260", + "verified_at": "2026-07-13T15:04:41Z" + } + }, + "environment": { + "architecture": "amd64", + "browser_revision": "1228", + "browser_version": "149.0.7827.55", + "container_image": "mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a", + "node": "22", + "os": "Ubuntu 24.04", + "platform": "linux/amd64", + "playwright": "1.61.0", + "viewport": { + "height": 768, + "width": 1024 + } + }, + "required_runs": 20, + "reference": { + "baseline": { + "path": "tests/snapshots/consumer-reference-card-grid.png", + "sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837" + }, + "source": { + "path": "tests/helpers/render-consumer-reference.mjs", + "sha256": "88802a948909d5e40470be6b5481766ce2de498e59c053ac68af370b46e72ca9" + } + }, + "runs": [ + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 1, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 2, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 3, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 4, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 5, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 6, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 7, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 8, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 9, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 10, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 11, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 12, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 13, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 14, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 15, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 16, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 17, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 18, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 19, + "screenshot_diff_pixels": 0 + }, + { + "architecture": "amd64", + "ax_sha256": "def6b05ea534a76b5362a436642f9790e464b4c6489a23118ff60b5eaee3aa1b", + "completed": true, + "dom_sha256": "ea28521d6b2e6b6d38b80a3a0411bddfd55da5f015b46271737c21c7f4098bb1", + "metadata_sha256": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c", + "png_sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837", + "run": 20, + "screenshot_diff_pixels": 0 + } + ], + "schema_version": "1.0", + "status": "completed" +} diff --git a/consumer-reference/baselines/manifest.json b/consumer-reference/baselines/manifest.json new file mode 100644 index 0000000..fc161ce --- /dev/null +++ b/consumer-reference/baselines/manifest.json @@ -0,0 +1,21 @@ +{ + "schema_version": "1.0", + "status": "proposed", + "baseline_owner_approval": "pending", + "platform": "linux/amd64", + "architecture": "amd64", + "container_image": "mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a", + "node": "22", + "os": "Ubuntu 24.04", + "playwright": "1.61.0", + "browser_revision": "1228", + "browser_version": "149.0.7827.55", + "viewport": { "width": 1024, "height": 768 }, + "source": { "path": "tests/helpers/render-consumer-reference.mjs", "sha256": "88802a948909d5e40470be6b5481766ce2de498e59c053ac68af370b46e72ca9" }, + "baseline": { "path": "tests/snapshots/consumer-reference-card-grid.png", "sha256": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837" }, + "calibration": { + "record": "consumer-reference/baselines/calibration.json", + "required_runs": 20, + "workflow_artifact_name": "chromium-sentinel-calibration-${run_id}-${sha}" + } +} diff --git a/consumer-reference/contract.md b/consumer-reference/contract.md index f4fed22..91fe712 100644 --- a/consumer-reference/contract.md +++ b/consumer-reference/contract.md @@ -27,6 +27,17 @@ Consumer reference reason: This handoff does not require consumer-specific visua `not_applicable` requires a sentence reason. A declared handoff points to exactly one canonical record and does not duplicate its owner, maturity, support, scope, or artifact data. +### Migration-Only Extension + +An ordinary implementation handoff uses only the shape above. When an existing consumer is being migrated and explicitly elects the experimental [Consumer Migration Readiness](../design-engineering/consumer-migration-readiness.md) method, add: + +```txt +Consumer migration conformance: declared +Consumer migration conformance record: path/to/consumer-conformance.json +``` + +This field is migration-only. Do not add it to generic blank handoffs, and do not replace their `Consumer reference: not_applicable` declaration. The conformance record is consumer-owned, follows `schema/consumer-conformance-record.schema.json`, and resolves relative to the consumer repository root. The consumer-reference validator resolves and executes every declared migration conformance record; it propagates conformance findings and, for applicable page evidence, derives the artifact root from the record's manifest path. Ordinary handoffs without this declaration do not execute the migration validator. + ## Repository-Local Record Boundary `declared` accepts only a normalized POSIX repository-relative path ending in `.json`. The path must exist beneath the current repository root and contain valid JSON. Absolute paths, URI schemes, network paths, query or fragment redirects, `..` segments, non-normalized paths, filesystem redirects, and symlink escapes are rejected. @@ -42,12 +53,28 @@ External consumers keep their canonical record in their own repository and apply - Repository-owned fixtures disclose `fixture_independence: "related"`; they do not count as independent consumers. - Current ownership truth is `owner.enforcement: "placeholder"` and `review_independence: "single_account"`. Boolean aliases are not accepted. +Every runtime scenario binds to regular files at the pinned consumer revision and to a strict JSON result receipt. The validator resolves the declared consumer repository and revision, recomputes each source digest from Git blobs, and checks the receipt against the scenario and consumer identity even when page evidence is `not_applicable`. Every adopted StyleGallery source must likewise resolve to a real path and heading anchor at its pinned StyleGallery revision. + ## Ownership And Dependency Consumer-reference infrastructure is owned by repository governance and validation review, currently represented by the same `@changeroa` placeholder account. This is one account, not independent review. Dependency flows from a consumer or profile to Layout. `layout/**`, `patterns/**`, `scripts/pattern-data.mjs`, and `CATALOG.md` must not import consumer-reference records, profile data, tokens, decorative values, or themes. +## Promotion Boundary + +`consumer-reference/policies/shared-experimental.json` is the canonical StyleGallery-local promotion policy. Its `>=2` independent-consumer gateway applies only to consumer-local → shared-experimental invariant eligibility. It does not promote visual identity and it does not define a stable threshold. Shared stable has no numeric adoption threshold. + +An RFC must record invariant scope, claim-scoped and regression evidence, canonical independence basis, actual owner and support capacity, compatibility, known issues and exceptions, migration and deprecation readiness, rollback triggers, provenance debt, and an explicit deferred or rejected decision. Normative accessibility, security, standards, or correctness fixes may waive adoption count only; every other duty remains binding. + +The tracked examples are synthetic `example_only: true`, `decision: "deferred"`, and have zero adopter attestations. They are not accepted or promoted RFCs. Editorial and terminal share one related fixture set and cannot be counted as independent. Review truth is the enum `review_independence: "single_account"`; boolean aliases are invalid. + +Promotion data is JSON-only. Palette, typography, imagery, motion character, and component skin remain consumer-local. A stable contract whose evidence later fails must be restricted, maintained, rolled back, or deprecated with migration; it cannot be silently relabeled experimental. + +The Chromium reference sentinel is an evidence probe, not a Layout theme or product conformance claim. Its proposed baseline is compared in the immutable `linux/amd64` Playwright image, CI never updates snapshots, and the ordinary sentinel remains nonblocking while `baseline_owner_approval` is `pending`. Twenty identical committed-workflow repeats calibrate repeatability; they do not substitute for explicit `@changeroa` baseline approval. Each aggregate run is derived from a zero exit record, the exact passing Playwright test identity, hash-verified PNG/DOM/AX bytes, and comparator proof written only after the zero-diff assertion passes; producer metadata cannot claim completion or a diff. + +Completed-CI repository, workflow, run ID and attempt, SHA, and artifact-name fields are workflow-recorded, self-asserted metadata, not an external attestation. The self-asserted repository field names the canonical upstream changeroa/StyleGallery; the self-asserted execution_repository field names the actual GitHub Actions repository and is limited to changeroa/StyleGallery or ark-jo/StyleGallery. The committed calibration's external_verification object records an independently checked GitHub Actions run and artifact API identity; artifact.api_digest is distinct from committed_ci.raw_evidence_sha256. Future CI aggregates remain awaiting_external_verification until their uploaded artifact API identity is independently checked. Linux/amd64 repeatability is externally verified only for committed run 29260372260; it does not establish baseline-owner approval or product suitability. Baseline-owner approval remains unclaimed until the named owner explicitly approves it. Synthetic fixtures validate rejection and acceptance behavior only; they are not authenticated provenance. + The no-dependency validator blocks direct spellings and literal fragments assembled through concatenation, template literals, or `path.join`. This bounded static check does not claim general JavaScript data-flow analysis; guarded sources must not hide reference paths behind variables or runtime computation. ## IA Navigation diff --git a/consumer-reference/fixtures/consumer-conformance/e2e-fixture.mjs b/consumer-reference/fixtures/consumer-conformance/e2e-fixture.mjs new file mode 100644 index 0000000..be7b5fa --- /dev/null +++ b/consumer-reference/fixtures/consumer-conformance/e2e-fixture.mjs @@ -0,0 +1,282 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { canonicalSourceManifest, digest } from "../../../scripts/page-evidence-contract.mjs"; +import { + cleanupFixture, + finalizeSession, + initializeConsumer, + startSession, + writeJson, + writeRunner, +} from "../../../scripts/page-evidence-fixture.mjs"; + +const styleGalleryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const validator = path.join(styleGalleryRoot, "scripts", "validate-consumer-conformance.mjs"); + +function runValidator(root, record, { artifactRoot, priorManifest } = {}) { + const args = [validator, "--root", root, "--record", record]; + if (artifactRoot) args.push("--artifact-root", artifactRoot); + if (priorManifest) args.push("--prior-manifest", priorManifest); + args.push("--json"); + const child = spawnSync(process.execPath, args, { cwd: styleGalleryRoot, encoding: "utf8" }); + let output; + try { output = JSON.parse(child.stdout); } + catch { output = { failures: [], parse_error: child.stderr || child.stdout }; } + return { output, status: child.status }; +} + +function codes(child) { + return child?.report?.failures?.map((entry) => entry.code) + ?? child?.output?.failures?.map((entry) => entry.code) + ?? []; +} + +function outcome(name, expected, child, observe = {}) { + const actualCodes = codes(child); + const childOk = expected === "ok:true" + ? child?.status === 0 && (child.report?.ok === true || child.output?.ok === true) + : child?.status !== 0 && actualCodes.includes(expected); + const ok = childOk && !Object.values(observe).includes(false); + return { actual: { codes: actualCodes, status: child?.status, ...observe }, expected, name, ok }; +} + +function sourceManifest(fixture) { + const failures = []; + const source = canonicalSourceManifest(fixture.root, fixture.relevantSources, failures); + if (!source || failures.length > 0) throw new Error(`source fixture failed: ${JSON.stringify(failures)}`); + return source; +} + +function createSession(fixture, template, options = {}) { + const artifactName = options.artifactName ?? "migration"; + const scenarioId = options.scenarioId ?? "migration-round-trip"; + const session = { + artifactRoot: path.join(fixture.root, "evidence", artifactName), + handoffFile: path.join(fixture.root, "records", `${artifactName}-handoff.json`), + recordFile: path.join(fixture.root, "records", `${artifactName}.json`), + runId: options.runId ?? `run-${artifactName}`, + scenarioId, + sessionId: options.sessionId ?? `session-${artifactName}`, + }; + const value = structuredClone(template); + const styleGalleryRevision = execFileSync("git", ["rev-parse", "HEAD"], { cwd: styleGalleryRoot, encoding: "utf8" }).trim(); + value.id = `${artifactName}-migration`; + value.consumer = { relevant_sources: fixture.relevantSources, repository: fixture.repository, revision: fixture.revision }; + value.page_evidence = { manifest: `evidence/${artifactName}/page-evidence-manifest.json`, status: "applicable" }; + value.scenarios[0] = { + ...value.scenarios[0], + argv: ["node", "tests/page.spec.js"], + evidence_method: "browser", + id: scenarioId, + result_artifact: `evidence/${artifactName}/runner/${scenarioId}.json`, + run_id: session.runId, + session_id: session.sessionId, + source_digest: sourceManifest(fixture).sha256, + }; + for (const dimension of Object.values(value.migration_dimensions)) if (dimension.status === "applicable") dimension.scenario_ids = [scenarioId]; + for (const mapping of value.adoption_mappings) { + mapping.scenario_ids = [scenarioId]; + mapping.stylegallery.revision = styleGalleryRevision; + } + options.mutateRecord?.(value); + writeJson(session.recordFile, value); + writeJson(session.handoffFile, { consumer_conformance_record: `records/${artifactName}.json`, consumer_migration_readiness: "declared" }); + return session; +} + +function startedSession(fixture, template, options) { + const session = createSession(fixture, template, options); + const start = startSession(fixture, session); + if (start.status !== 0) throw new Error(`session start failed: ${JSON.stringify(start.report)}`); + return session; +} + +function isolated(template, run) { + const fixture = initializeConsumer(); + try { return run(fixture, template); } + finally { cleanupFixture(fixture); } +} + +export function initializeCompletedConsumer(template) { + const fixture = initializeConsumer(); + try { + const session = startedSession(fixture, template); + const finalized = finalizeSession(fixture, session, writeRunner(fixture, session)); + if (finalized.status !== 0) throw new Error(`session finalize failed: ${JSON.stringify(finalized.report)}`); + return { ...fixture, artifactRoot: session.artifactRoot, recordReference: "records/migration.json" }; + } catch (error) { + cleanupFixture(fixture); + throw error; + } +} + +export function cleanupCompletedConsumer(fixture) { + cleanupFixture(fixture); +} + +function mutatedCompletedPacket(template, { caseName, expected, mutateRecord }) { + return isolated(template, (fixture, source) => { + const session = startedSession(fixture, source, { artifactName: caseName }); + const finalized = finalizeSession(fixture, session, writeRunner(fixture, session)); + if (finalized.status !== 0) throw new Error(`session finalize failed: ${JSON.stringify(finalized.report)}`); + const value = JSON.parse(fs.readFileSync(session.recordFile, "utf8")); + mutateRecord(value); + writeJson(session.recordFile, value); + return outcome(caseName, expected, runValidator(fixture.root, `records/${caseName}.json`, { artifactRoot: session.artifactRoot })); + }); +} + +const cases = { + "end-to-end-consumer": (template) => isolated(template, (fixture, source) => { + const session = startedSession(fixture, source); + const finalized = finalizeSession(fixture, session, writeRunner(fixture, session)); + if (finalized.status !== 0) throw new Error(`session finalize failed: ${JSON.stringify(finalized.report)}`); + const handoff = JSON.parse(fs.readFileSync(session.handoffFile, "utf8")); + const child = runValidator(fixture.root, handoff.consumer_conformance_record, { artifactRoot: session.artifactRoot }); + return outcome("end-to-end-consumer", "ok:true", child, { + artifactRootOutsideStyleGallery: !session.artifactRoot.startsWith(styleGalleryRoot), + checkedDimensions: child.output.checkedDimensions, + checkedMappings: child.output.checkedMappings, + checkedPageArtifacts: child.output.checkedPageArtifacts, + checkedPageScenarios: child.output.checkedPageScenarios, + handoffLinked: handoff.consumer_migration_readiness === "declared" && handoff.consumer_conformance_record === "records/migration.json", + pageSessionId: child.output.pageSessionId, + }); + }), + "missing-mapping": (template) => isolated(template, (fixture, source) => { + const session = startedSession(fixture, source, { mutateRecord: (value) => { + const unit = { ...value.scenarios[0], evidence_method: "unit", id: "unit-migration", result_artifact: "evidence/results/unit-migration.json" }; + value.scenarios.push(unit); + value.adoption_mappings[0].scenario_ids = [unit.id]; + } }); + const finalized = finalizeSession(fixture, session, writeRunner(fixture, session)); + if (finalized.status !== 0) throw new Error(`session finalize failed: ${JSON.stringify(finalized.report)}`); + return outcome("missing-mapping", "page_evidence_adoption_mapping_missing", runValidator(fixture.root, "records/migration.json", { artifactRoot: session.artifactRoot })); + }), + "applicable-without-runtime": (template) => isolated(template, (fixture, source) => { + const session = createSession(fixture, source, { mutateRecord: (value) => { value.scenarios[0].evidence_method = "unit"; } }); + fs.mkdirSync(session.artifactRoot, { recursive: true }); + return outcome("applicable-without-runtime", "page_evidence_runtime_scenario_required", runValidator(fixture.root, "records/migration.json", { artifactRoot: session.artifactRoot })); + }), + "dirty-source": (template) => isolated(template, (fixture, source) => { + const session = createSession(fixture, source); + fs.appendFileSync(path.join(fixture.root, fixture.relevantSources[0]), "// dirty\n"); + return outcome("dirty-source", "page_evidence_source_dirty", startSession(fixture, session)); + }), + "failed-runner": (template) => isolated(template, (fixture, source) => { + const session = startedSession(fixture, source); + return outcome("failed-runner", "page_evidence_runner_failed", finalizeSession(fixture, session, writeRunner(fixture, session, { status: "failed" }))); + }), + "cross-session-reuse": (template) => isolated(template, (fixture, source) => { + const target = startedSession(fixture, source, { artifactName: "target" }); + const alternate = startedSession(fixture, source, { artifactName: "alternate" }); + const targetRunner = writeRunner(fixture, target); + const alternateRunner = writeRunner(fixture, alternate); + fs.copyFileSync(alternateRunner.runnerFile, targetRunner.runnerFile); + fs.copyFileSync(path.join(alternate.artifactRoot, alternateRunner.artifactReference), path.join(target.artifactRoot, targetRunner.artifactReference)); + return outcome("cross-session-reuse", "page_evidence_session_mismatch", finalizeSession(fixture, target, targetRunner)); + }), + "unmanifested-artifact": (template) => isolated(template, (fixture, source) => { + const session = startedSession(fixture, source); + const runner = writeRunner(fixture, session); + fs.writeFileSync(path.join(session.artifactRoot, "rogue.png"), "rogue"); + return outcome("unmanifested-artifact", "page_evidence_artifact_unmanifested", finalizeSession(fixture, session, runner)); + }), + "reuse-source-drift": (template) => isolated(template, (fixture, source) => { + const prior = startedSession(fixture, source, { artifactName: "prior" }); + const priorFinalized = finalizeSession(fixture, prior, writeRunner(fixture, prior)); + if (priorFinalized.status !== 0) throw new Error(`prior finalize failed: ${JSON.stringify(priorFinalized.report)}`); + fs.appendFileSync(path.join(fixture.root, fixture.relevantSources[0]), "// next revision\n"); + execFileSync("git", ["add", fixture.relevantSources[0]], { cwd: fixture.root }); + execFileSync("git", ["commit", "-m", "next fixture revision"], { cwd: fixture.root, stdio: "ignore" }); + fixture.revision = execFileSync("git", ["rev-parse", "HEAD"], { cwd: fixture.root, encoding: "utf8" }).trim(); + const current = startedSession(fixture, source, { artifactName: "current" }); + const priorManifest = path.join(prior.artifactRoot, "page-evidence-manifest.json"); + const evidence = { + kind: "reused", + prior_manifest_sha256: digest(fs.readFileSync(priorManifest)), + prior_run_id: prior.runId, + prior_scenario_id: prior.scenarioId, + prior_session_id: prior.sessionId, + }; + const runner = writeRunner(fixture, current, { evidence, writeArtifact: false }); + return outcome("reuse-source-drift", "page_evidence_reuse_source_mismatch", finalizeSession(fixture, current, runner, priorManifest)); + }), + "source-set-mismatch": (template) => mutatedCompletedPacket(template, { + caseName: "source-set-mismatch", + expected: "page_evidence_source_set_mismatch", + mutateRecord: (value) => { value.consumer.relevant_sources = value.consumer.relevant_sources.slice(0, 1); }, + }), + "repository-mismatch": (template) => mutatedCompletedPacket(template, { + caseName: "repository-mismatch", + expected: "page_evidence_repository_mismatch", + mutateRecord: (value) => { value.consumer.repository = "other/consumer"; }, + }), + "revision-mismatch": (template) => mutatedCompletedPacket(template, { + caseName: "revision-mismatch", + expected: "page_evidence_revision_mismatch", + mutateRecord: (value) => { value.consumer.revision = "0".repeat(40); }, + }), + "source-digest-mismatch": (template) => mutatedCompletedPacket(template, { + caseName: "source-digest-mismatch", + expected: "page_evidence_source_mismatch", + mutateRecord: (value) => { value.scenarios[0].source_digest = "0".repeat(64); }, + }), + "intent-scenario-mismatch": (template) => mutatedCompletedPacket(template, { + caseName: "intent-scenario-mismatch", + expected: "page_evidence_intent_scenario_mismatch", + mutateRecord: (value) => { + const additional = { ...value.scenarios[0], id: "secondary-browser", result_artifact: "evidence/intent-scenario-mismatch/runner/secondary-browser.json" }; + value.scenarios.push(additional); + value.migration_dimensions.route_parity.scenario_ids.push(additional.id); + value.adoption_mappings[0].scenario_ids.push(additional.id); + }, + }), + "review-by-missing": (template) => isolated(template, (fixture, source) => { + const session = startedSession(fixture, source, { artifactName: "review-by-missing" }); + const finalized = finalizeSession(fixture, session, writeRunner(fixture, session)); + if (finalized.status !== 0) throw new Error(`session finalize failed: ${JSON.stringify(finalized.report)}`); + const manifestFile = path.join(session.artifactRoot, "page-evidence-manifest.json"); + const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8")); + delete manifest.review_by; + writeJson(manifestFile, manifest); + return outcome("review-by-missing", "page_evidence_manifest_schema_invalid", runValidator(fixture.root, "records/review-by-missing.json", { artifactRoot: session.artifactRoot })); + }), + "manifest-network-url": (template) => isolated(template, (fixture, source) => { + const session = createSession(fixture, source, { artifactName: "manifest-network-url", mutateRecord: (value) => { + value.page_evidence.manifest = "https://example.com/page-evidence-manifest.json"; + } }); + fs.mkdirSync(session.artifactRoot, { recursive: true }); + const child = runValidator(fixture.root, "records/manifest-network-url.json", { artifactRoot: session.artifactRoot }); + return outcome("manifest-network-url", "consumer_conformance_schema_invalid", child, { + noRuntimeExecution: child.output.checkedRuntimeCommands === 0, + }); + }), + "manifest-symlink": (template) => isolated(template, (fixture, source) => { + const session = startedSession(fixture, source, { artifactName: "manifest-symlink" }); + const finalized = finalizeSession(fixture, session, writeRunner(fixture, session)); + if (finalized.status !== 0) throw new Error(`session finalize failed: ${JSON.stringify(finalized.report)}`); + const manifest = path.join(session.artifactRoot, "page-evidence-manifest.json"); + const redirected = path.join(session.artifactRoot, "redirected-manifest.json"); + fs.renameSync(manifest, redirected); + fs.symlinkSync(redirected, manifest); + return outcome("manifest-symlink", "page_evidence_manifest_symlink", runValidator(fixture.root, "records/manifest-symlink.json", { artifactRoot: session.artifactRoot })); + }), + "artifact-root-redirect": (template) => isolated(template, (fixture, source) => { + const session = startedSession(fixture, source, { artifactName: "artifact-root-redirect" }); + const finalized = finalizeSession(fixture, session, writeRunner(fixture, session)); + if (finalized.status !== 0) throw new Error(`session finalize failed: ${JSON.stringify(finalized.report)}`); + const redirected = `${session.artifactRoot}-redirected`; + fs.renameSync(session.artifactRoot, redirected); + fs.symlinkSync(redirected, session.artifactRoot); + return outcome("artifact-root-redirect", "page_evidence_artifact_symlink", runValidator(fixture.root, "records/artifact-root-redirect.json", { artifactRoot: session.artifactRoot })); + }), +}; + +export const endToEndCaseNames = Object.freeze(Object.keys(cases)); + +export function runEndToEndCase(name, template) { + return cases[name](template); +} diff --git a/consumer-reference/fixtures/consumer-conformance/manifest.json b/consumer-reference/fixtures/consumer-conformance/manifest.json new file mode 100644 index 0000000..e8f1c97 --- /dev/null +++ b/consumer-reference/fixtures/consumer-conformance/manifest.json @@ -0,0 +1,102 @@ +{ + "id": "consumer-conformance-fixture-inventory", + "invalid_cases": [ + { + "expected_code": "migration_dimension_required", + "name": "omitted-dimension" + }, + { + "expected_code": "migration_dimension_scenario_required", + "name": "applicable-without-scenario" + }, + { + "expected_code": "runtime_evidence_method_invalid", + "name": "source-regex-runtime-proof" + }, + { + "expected_code": "adoption_scenario_unknown", + "name": "orphan-adoption-scenario" + }, + { + "expected_code": "adoption_consumer_target_invalid", + "name": "malformed-local-target" + }, + { + "expected_code": "adoption_stylegallery_revision_unpinned", + "name": "unpinned-stylegallery-revision" + }, + { + "expected_code": "adoption_stylegallery_revision_unknown", + "name": "unknown-stylegallery-revision" + }, + { + "expected_code": "adoption_stylegallery_path_missing", + "name": "missing-stylegallery-path" + }, + { + "expected_code": "adoption_stylegallery_anchor_missing", + "name": "missing-stylegallery-anchor" + }, + { + "expected_code": "adoption_debt_incomplete", + "name": "debt-without-lifecycle" + }, + { + "expected_code": "consumer_source_missing", + "name": "missing-source" + }, + { + "expected_code": "consumer_source_symlink", + "name": "symlink-source" + }, + { + "expected_code": "runtime_command_checkout_symlink", + "name": "checkout-symlink" + }, + { + "expected_code": "runtime_result_artifact_missing", + "name": "missing-result-artifact" + }, + { + "expected_code": "runtime_result_artifact_symlink", + "name": "symlink-result-artifact" + }, + { + "expected_code": "runtime_result_artifact_json_invalid", + "name": "invalid-result-artifact-json" + }, + { + "expected_code": "runtime_result_artifact_mismatch", + "name": "fabricated-result-artifact" + }, + { + "expected_code": "consumer_repository_mismatch", + "name": "fabricated-repository" + }, + { + "expected_code": "consumer_revision_unknown", + "name": "fabricated-revision" + }, + { + "expected_code": "runtime_source_digest_mismatch", + "name": "fabricated-source-digest" + }, + { + "expected_code": "runtime_command_failed", + "name": "nonzero-command" + }, + { + "expected_code": "runtime_command_result_missing", + "name": "forged-noop-result" + }, + { + "expected_code": "consumer_repository_mismatch", + "name": "github-repository-spoof" + } + ], + "schema_record": "consumer-reference/schema/consumer-conformance-record.schema.json", + "schema_version": "1.0", + "valid_records": [ + "valid-migration.json" + ] +} diff --git a/consumer-reference/fixtures/consumer-conformance/runtime-proof.mjs b/consumer-reference/fixtures/consumer-conformance/runtime-proof.mjs new file mode 100644 index 0000000..0e59e47 --- /dev/null +++ b/consumer-reference/fixtures/consumer-conformance/runtime-proof.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const resultReference = process.env.STYLEGALLERY_RESULT_ARTIFACT; +const contextValue = process.env.STYLEGALLERY_RUNTIME_CONTEXT; +assert.ok(resultReference && contextValue, "the validator must provide the result path and runtime context"); +assert.equal(path.posix.normalize(resultReference), resultReference); +assert.ok(!path.posix.isAbsolute(resultReference) && !resultReference.split("/").includes("..")); + +if (process.argv.includes("--lock-worktree")) { + const locked = spawnSync("git", ["worktree", "lock", "."], { encoding: "utf8" }); + assert.equal(locked.status, 0, locked.stderr); +} + +const context = JSON.parse(contextValue); +const persisted = JSON.parse(JSON.stringify({ drawer: "open", route: "/gallery", selection: null })); +assert.deepEqual(persisted, { drawer: "open", route: "/gallery", selection: null }); + +const output = { + ...context, + record_kind: "consumer_migration_scenario_result", + recorded_at: "2026-07-21T00:00:00Z", + schema_version: "1.0", + status: "passed", +}; +const resultFile = path.resolve(process.cwd(), resultReference); +fs.mkdirSync(path.dirname(resultFile), { recursive: true }); +fs.writeFileSync(resultFile, `${JSON.stringify(output, null, 2)}\n`, { flag: "wx" }); diff --git a/consumer-reference/fixtures/consumer-conformance/valid-migration-result.json b/consumer-reference/fixtures/consumer-conformance/valid-migration-result.json new file mode 100644 index 0000000..0aacaf8 --- /dev/null +++ b/consumer-reference/fixtures/consumer-conformance/valid-migration-result.json @@ -0,0 +1,18 @@ +{ + "argv": [ + "node", + "consumer-reference/fixtures/consumer-conformance/runtime-proof.mjs" + ], + "evidence_method": "integration", + "exit_code": 0, + "record_kind": "consumer_migration_scenario_result", + "recorded_at": "2026-07-21T00:00:00Z", + "repository": "changeroa/StyleGallery", + "revision": "84bf420d568f68021117f8142e6d982b4966de2f", + "run_id": "run-20260721-001", + "scenario_id": "migration-round-trip", + "schema_version": "1.0", + "session_id": "session-20260721-001", + "source_digest": "6d90893774e2228ffe9ae0286f5b3392b103ea1bf5e571fcde2f7a861d76372c", + "status": "passed" +} diff --git a/consumer-reference/fixtures/consumer-conformance/valid-migration.json b/consumer-reference/fixtures/consumer-conformance/valid-migration.json new file mode 100644 index 0000000..8fe28c0 --- /dev/null +++ b/consumer-reference/fixtures/consumer-conformance/valid-migration.json @@ -0,0 +1,142 @@ +{ + "adoption_mappings": [ + { + "consumer_target": { + "identity": "GalleryShell", + "kind": "component" + }, + "debt": [], + "deviations": [], + "local_decision": { + "decision": "Keep the spacing value consumer-local and map only the layout unit role.", + "identity": "--space-shell-gap", + "kind": "token" + }, + "scenario_ids": [ + "migration-round-trip" + ], + "stylegallery": { + "anchor": "#recommended-pattern-stack", + "path": "recipes/dashboard.md", + "revision": "66601da6e81c69674edf5c1b9b1b0862d4597fe9" + } + } + ], + "claim_boundary": { + "certifies_accessibility": false, + "certifies_product_correctness": false, + "scope": "This record binds migration scenarios to consumer-owned runtime evidence only." + }, + "consumer": { + "relevant_sources": [ + "consumer-reference/fixtures/consumer-conformance/runtime-proof.mjs", + "consumer-reference/schema/consumer-conformance-record.schema.json", + "scripts/consumer-conformance-contract.mjs", + "scripts/validate-consumer-conformance.mjs" + ], + "repository": "changeroa/StyleGallery", + "revision": "84bf420d568f68021117f8142e6d982b4966de2f" + }, + "id": "example-consumer-migration", + "migration_dimensions": { + "action_parity": { + "scenario_ids": [ + "migration-round-trip" + ], + "status": "applicable" + }, + "atomic_batch_behavior": { + "reason": "The migrated surface exposes no batch operation in this scope.", + "status": "not_applicable" + }, + "behavior_inventory": { + "scenario_ids": [ + "migration-round-trip" + ], + "status": "applicable" + }, + "contract_precedence": { + "scenario_ids": [ + "migration-round-trip" + ], + "status": "applicable" + }, + "defaults_tri_state_mapping": { + "reason": "The migrated fields do not have defaults or tri-state semantics.", + "status": "not_applicable" + }, + "direct_mutation": { + "scenario_ids": [ + "migration-round-trip" + ], + "status": "applicable" + }, + "exact_time_boundary": { + "reason": "The migrated behavior has no exact-time scheduling boundary.", + "status": "not_applicable" + }, + "field_parity": { + "scenario_ids": [ + "migration-round-trip" + ], + "status": "applicable" + }, + "indirect_mutation": { + "scenario_ids": [ + "migration-round-trip" + ], + "status": "applicable" + }, + "persistence_round_trip": { + "scenario_ids": [ + "migration-round-trip" + ], + "status": "applicable" + }, + "reset_boundary": { + "scenario_ids": [ + "migration-round-trip" + ], + "status": "applicable" + }, + "route_parity": { + "scenario_ids": [ + "migration-round-trip" + ], + "status": "applicable" + }, + "state_transitions": { + "scenario_ids": [ + "migration-round-trip" + ], + "status": "applicable" + } + }, + "page_evidence": { + "reason": "Page evidence is supplied by the consumer during end-to-end composition.", + "status": "not_applicable" + }, + "record_kind": "consumer_migration_conformance", + "scenarios": [ + { + "assertions": [ + "The drawer state and persisted route data match after a reload." + ], + "argv": [ + "node", + "consumer-reference/fixtures/consumer-conformance/runtime-proof.mjs" + ], + "evidence_method": "integration", + "exit_code": 0, + "id": "migration-round-trip", + "observable_actions": [ + "Run the synthetic migration fixture and inspect its machine-readable result." + ], + "result_artifact": "consumer-reference/fixtures/consumer-conformance/valid-migration-result.json", + "run_id": "run-20260721-001", + "session_id": "session-20260721-001", + "source_digest": "6d90893774e2228ffe9ae0286f5b3392b103ea1bf5e571fcde2f7a861d76372c" + } + ], + "schema_version": "1.0" +} diff --git a/consumer-reference/fixtures/promotion/invalid-blocking-evidence.json b/consumer-reference/fixtures/promotion/invalid-blocking-evidence.json new file mode 100644 index 0000000..5d040ac --- /dev/null +++ b/consumer-reference/fixtures/promotion/invalid-blocking-evidence.json @@ -0,0 +1,26 @@ +{ + "attestations": [], + "compatibility": { "breaking": false, "surface": "Synthetic invariant contract surface." }, + "decision": "deferred", + "deprecation": { "plan": "No deprecation while deferred.", "required": false }, + "eligibility_basis": "full_contract", + "evidence": [ + { "id": "blocking", "kind": "blocking", "reference": "synthetic-fixture:blocking", "status": "failed" }, + { "id": "regression", "kind": "regression", "reference": "synthetic-fixture:regression", "status": "passed" } + ], + "example_only": true, + "exceptions": [], + "id": "invalid-blocking-evidence", + "known_issues": ["The blocking evidence failed."], + "migration": { "plan": "No migration while deferred.", "required": false }, + "normative_bypass": "none", + "owner": { "enforcement": "placeholder", "migration": "Synthetic migration owner", "rollback": "Synthetic rollback owner", "scope": "Repository governance owner", "support": "Synthetic support owner" }, + "provenance": { "debt_resolved": false, "source": "Synthetic fixture only" }, + "review_independence": "single_account", + "rollback": { "action": "Keep deferred.", "triggers": ["Blocking evidence fails."] }, + "schema_version": "1.0", + "scope": { "aesthetic": false, "kind": "invariant", "summary": "Incorrectly proceed while blocking evidence is failed." }, + "stable_scope_decision": false, + "support": { "capacity": "committed", "status": "active" }, + "transition": { "from": "consumer_local", "to": "shared_experimental" } +} diff --git a/consumer-reference/fixtures/promotion/invalid-count-only.json b/consumer-reference/fixtures/promotion/invalid-count-only.json new file mode 100644 index 0000000..66e339e --- /dev/null +++ b/consumer-reference/fixtures/promotion/invalid-count-only.json @@ -0,0 +1,29 @@ +{ + "attestations": [ + { "attested_by": "Synthetic owner A", "context_id": "context-a", "consumer_id": "consumer-a", "count_toward_gate": true, "organization_id": "organization-a", "relationship": "independent" }, + { "attested_by": "Synthetic owner B", "context_id": "context-b", "consumer_id": "consumer-b", "count_toward_gate": true, "organization_id": "organization-b", "relationship": "independent" } + ], + "compatibility": { "breaking": false, "surface": "Synthetic invariant contract surface." }, + "decision": "deferred", + "deprecation": { "plan": "No deprecation while deferred.", "required": false }, + "eligibility_basis": "consumer_count_only", + "evidence": [ + { "id": "claim", "kind": "claim_scoped", "reference": "synthetic-fixture:claim", "status": "passed" }, + { "id": "regression", "kind": "regression", "reference": "synthetic-fixture:regression", "status": "passed" } + ], + "example_only": true, + "exceptions": [], + "id": "invalid-count-only", + "known_issues": [], + "migration": { "plan": "No migration while deferred.", "required": false }, + "normative_bypass": "none", + "owner": { "enforcement": "placeholder", "migration": "Synthetic migration owner", "rollback": "Synthetic rollback owner", "scope": "Repository governance owner", "support": "Synthetic support owner" }, + "provenance": { "debt_resolved": false, "source": "Synthetic fixture only" }, + "review_independence": "single_account", + "rollback": { "action": "Keep deferred.", "triggers": ["Operational duties are incomplete."] }, + "schema_version": "1.0", + "scope": { "aesthetic": false, "kind": "invariant", "summary": "Incorrectly use adoption count as the whole promotion decision." }, + "stable_scope_decision": false, + "support": { "capacity": "committed", "status": "active" }, + "transition": { "from": "consumer_local", "to": "shared_experimental" } +} diff --git a/consumer-reference/fixtures/promotion/invalid-missing-owner-migration.json b/consumer-reference/fixtures/promotion/invalid-missing-owner-migration.json new file mode 100644 index 0000000..b61c5a5 --- /dev/null +++ b/consumer-reference/fixtures/promotion/invalid-missing-owner-migration.json @@ -0,0 +1,25 @@ +{ + "attestations": [], + "compatibility": { "breaking": true, "surface": "Synthetic breaking contract surface." }, + "decision": "deferred", + "deprecation": { "plan": "A migration is required before deprecation.", "required": true }, + "eligibility_basis": "full_contract", + "evidence": [ + { "id": "claim", "kind": "claim_scoped", "reference": "synthetic-fixture:claim", "status": "passed" }, + { "id": "regression", "kind": "regression", "reference": "synthetic-fixture:regression", "status": "passed" } + ], + "example_only": true, + "exceptions": [], + "id": "invalid-missing-owner-migration", + "known_issues": [], + "normative_bypass": "none", + "owner": { "enforcement": "placeholder", "rollback": "Synthetic rollback owner", "scope": "Repository governance owner", "support": "Synthetic support owner" }, + "provenance": { "debt_resolved": false, "source": "Synthetic fixture only" }, + "review_independence": "single_account", + "rollback": { "action": "Keep deferred.", "triggers": ["Migration duty is unowned."] }, + "schema_version": "1.0", + "scope": { "aesthetic": false, "kind": "invariant", "summary": "Incorrectly omit migration plan and migration ownership." }, + "stable_scope_decision": false, + "support": { "capacity": "committed", "status": "active" }, + "transition": { "from": "consumer_local", "to": "shared_experimental" } +} diff --git a/consumer-reference/fixtures/promotion/invalid-normative-bypass-no-regression.json b/consumer-reference/fixtures/promotion/invalid-normative-bypass-no-regression.json new file mode 100644 index 0000000..d164722 --- /dev/null +++ b/consumer-reference/fixtures/promotion/invalid-normative-bypass-no-regression.json @@ -0,0 +1,25 @@ +{ + "attestations": [], + "compatibility": { "breaking": false, "surface": "Synthetic correctness contract surface." }, + "decision": "deferred", + "deprecation": { "plan": "No deprecation while deferred.", "required": false }, + "eligibility_basis": "full_contract", + "evidence": [ + { "id": "claim", "kind": "claim_scoped", "reference": "synthetic-fixture:claim", "status": "passed" } + ], + "example_only": true, + "exceptions": [], + "id": "invalid-normative-bypass-no-regression", + "known_issues": [], + "migration": { "plan": "Apply only after compatibility review.", "required": false }, + "normative_bypass": "normative_correctness", + "owner": { "enforcement": "placeholder", "migration": "Synthetic migration owner", "rollback": "Synthetic rollback owner", "scope": "Repository governance owner", "support": "Synthetic support owner" }, + "provenance": { "debt_resolved": false, "source": "Synthetic fixture only" }, + "review_independence": "single_account", + "rollback": { "action": "Keep deferred.", "triggers": ["Regression evidence is absent or fails."] }, + "schema_version": "1.0", + "scope": { "aesthetic": false, "kind": "normative_correctness", "summary": "Incorrectly bypass adoption count without regression proof." }, + "stable_scope_decision": false, + "support": { "capacity": "committed", "status": "active" }, + "transition": { "from": "consumer_local", "to": "shared_experimental" } +} diff --git a/consumer-reference/fixtures/promotion/invalid-related-counted-independent.json b/consumer-reference/fixtures/promotion/invalid-related-counted-independent.json new file mode 100644 index 0000000..125426f --- /dev/null +++ b/consumer-reference/fixtures/promotion/invalid-related-counted-independent.json @@ -0,0 +1,45 @@ +{ + "attestations": [ + { + "attested_by": "Synthetic owner", + "context_id": "editorial", + "consumer_id": "editorial-reference-profile", + "count_toward_gate": true, + "organization_id": "stylegallery", + "profile_record": "design-engineering/reference-profiles/governed-local/editorial/profile.json", + "relationship": "independent" + }, + { + "attested_by": "Synthetic owner", + "context_id": "terminal", + "consumer_id": "terminal-reference-profile", + "count_toward_gate": true, + "organization_id": "stylegallery", + "profile_record": "design-engineering/reference-profiles/governed-local/terminal/profile.json", + "relationship": "independent" + } + ], + "compatibility": { "breaking": false, "surface": "Synthetic invariant contract surface." }, + "decision": "deferred", + "deprecation": { "plan": "No deprecation while deferred.", "required": false }, + "eligibility_basis": "full_contract", + "evidence": [ + { "id": "claim", "kind": "claim_scoped", "reference": "synthetic-fixture:claim", "status": "passed" }, + { "id": "regression", "kind": "regression", "reference": "synthetic-fixture:regression", "status": "passed" } + ], + "example_only": true, + "exceptions": [], + "id": "invalid-related-counted-independent", + "known_issues": [], + "migration": { "plan": "No migration while deferred.", "required": false }, + "normative_bypass": "none", + "owner": { "enforcement": "placeholder", "migration": "Synthetic migration owner", "rollback": "Synthetic rollback owner", "scope": "Repository governance owner", "support": "Synthetic support owner" }, + "provenance": { "debt_resolved": false, "source": "Synthetic fixture only" }, + "review_independence": "single_account", + "rollback": { "action": "Keep deferred.", "triggers": ["Independence is not proven."] }, + "schema_version": "1.0", + "scope": { "aesthetic": false, "kind": "invariant", "summary": "Incorrectly count related local profiles as independent consumers." }, + "stable_scope_decision": false, + "support": { "capacity": "committed", "status": "active" }, + "transition": { "from": "consumer_local", "to": "shared_experimental" } +} diff --git a/consumer-reference/fixtures/promotion/invalid-stable-ended-support.json b/consumer-reference/fixtures/promotion/invalid-stable-ended-support.json new file mode 100644 index 0000000..d936718 --- /dev/null +++ b/consumer-reference/fixtures/promotion/invalid-stable-ended-support.json @@ -0,0 +1,26 @@ +{ + "attestations": [], + "compatibility": { "breaking": false, "surface": "Synthetic stable contract surface." }, + "decision": "deferred", + "deprecation": { "plan": "A consumed stable contract requires a named migration before deprecation.", "required": true }, + "eligibility_basis": "full_contract", + "evidence": [ + { "id": "claim", "kind": "claim_scoped", "reference": "synthetic-fixture:claim", "status": "passed" }, + { "id": "regression", "kind": "regression", "reference": "synthetic-fixture:regression", "status": "passed" } + ], + "example_only": true, + "exceptions": [], + "id": "invalid-stable-ended-support", + "known_issues": [], + "migration": { "plan": "Migrate every consumed surface before removal.", "required": true }, + "normative_bypass": "none", + "owner": { "enforcement": "placeholder", "migration": "Synthetic migration owner", "rollback": "Synthetic rollback owner", "scope": "Repository governance owner", "support": "Synthetic support owner" }, + "provenance": { "debt_resolved": true, "source": "Synthetic fixture only" }, + "review_independence": "single_account", + "rollback": { "action": "Maintain or deprecate with migration.", "triggers": ["Stable support capacity ends."] }, + "schema_version": "1.0", + "scope": { "aesthetic": false, "kind": "invariant", "summary": "Incorrectly propose stable status after support has ended." }, + "stable_scope_decision": true, + "support": { "capacity": "No remaining support capacity", "status": "ended" }, + "transition": { "from": "shared_experimental", "to": "stable" } +} diff --git a/consumer-reference/fixtures/promotion/manifest.json b/consumer-reference/fixtures/promotion/manifest.json new file mode 100644 index 0000000..c4bc9f2 --- /dev/null +++ b/consumer-reference/fixtures/promotion/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "promotion-fixture-inventory", + "invalid_records": [ + { "expected_code": "promotion_blocking_evidence_failed", "file": "invalid-blocking-evidence.json" }, + { "expected_code": "promotion_count_only_forbidden", "file": "invalid-count-only.json" }, + { "expected_code": "promotion_owner_migration_required", "file": "invalid-missing-owner-migration.json" }, + { "expected_code": "promotion_normative_regression_required", "file": "invalid-normative-bypass-no-regression.json" }, + { "expected_code": "promotion_related_consumer_counted", "file": "invalid-related-counted-independent.json" }, + { "expected_code": "stable_support_ended", "file": "invalid-stable-ended-support.json" } + ], + "policy_record": "consumer-reference/policies/shared-experimental.json", + "schema_record": "consumer-reference/schema/promotion-rfc.schema.json", + "schema_version": "1.0", + "valid_records": ["valid-deferred-example.json", "valid-normative-bypass.json"] +} diff --git a/consumer-reference/fixtures/promotion/valid-deferred-example.json b/consumer-reference/fixtures/promotion/valid-deferred-example.json new file mode 100644 index 0000000..702dfbb --- /dev/null +++ b/consumer-reference/fixtures/promotion/valid-deferred-example.json @@ -0,0 +1,67 @@ +{ + "attestations": [], + "compatibility": { + "breaking": false, + "surface": "Synthetic invariant contract surface." + }, + "decision": "deferred", + "deprecation": { + "plan": "No deprecation is required while this synthetic proposal remains deferred.", + "required": false + }, + "eligibility_basis": "full_contract", + "evidence": [ + { + "id": "claim-scoped-example", + "kind": "claim_scoped", + "reference": "synthetic-fixture:claim-scoped-example", + "status": "passed" + }, + { + "id": "regression-example", + "kind": "regression", + "reference": "synthetic-fixture:regression-example", + "status": "passed" + } + ], + "example_only": true, + "exceptions": [], + "id": "valid-deferred-example", + "known_issues": [], + "migration": { + "plan": "No migration begins unless a later separately reviewed decision changes the deferred status.", + "required": false + }, + "normative_bypass": "none", + "owner": { + "enforcement": "placeholder", + "migration": "Synthetic migration owner", + "rollback": "Synthetic rollback owner", + "scope": "Repository governance owner", + "support": "Synthetic support owner" + }, + "provenance": { + "debt_resolved": false, + "source": "Synthetic fixture only" + }, + "review_independence": "single_account", + "rollback": { + "action": "Keep the proposal deferred and remove any unsupported shared routing.", + "triggers": ["Blocking evidence fails or the scope expands beyond the reviewed invariant."] + }, + "schema_version": "1.0", + "scope": { + "aesthetic": false, + "kind": "invariant", + "summary": "Demonstrate a synthetic invariant promotion proposal without claiming adoption." + }, + "stable_scope_decision": false, + "support": { + "capacity": "committed", + "status": "active" + }, + "transition": { + "from": "consumer_local", + "to": "shared_experimental" + } +} diff --git a/consumer-reference/fixtures/promotion/valid-normative-bypass.json b/consumer-reference/fixtures/promotion/valid-normative-bypass.json new file mode 100644 index 0000000..cdf3a5d --- /dev/null +++ b/consumer-reference/fixtures/promotion/valid-normative-bypass.json @@ -0,0 +1,67 @@ +{ + "attestations": [], + "compatibility": { + "breaking": false, + "surface": "Synthetic correctness contract surface." + }, + "decision": "deferred", + "deprecation": { + "plan": "No deprecation is required for this deferred synthetic correction.", + "required": false + }, + "eligibility_basis": "full_contract", + "evidence": [ + { + "id": "claim-scoped-correctness", + "kind": "claim_scoped", + "reference": "synthetic-fixture:claim-scoped-correctness", + "status": "passed" + }, + { + "id": "regression-correctness", + "kind": "regression", + "reference": "synthetic-fixture:regression-correctness", + "status": "passed" + } + ], + "example_only": true, + "exceptions": [], + "id": "valid-normative-bypass", + "known_issues": [], + "migration": { + "plan": "Apply only after compatibility review; keep a bounded rollback path.", + "required": false + }, + "normative_bypass": "normative_correctness", + "owner": { + "enforcement": "placeholder", + "migration": "Synthetic migration owner", + "rollback": "Synthetic rollback owner", + "scope": "Repository governance owner", + "support": "Synthetic support owner" + }, + "provenance": { + "debt_resolved": false, + "source": "Synthetic fixture only" + }, + "review_independence": "single_account", + "rollback": { + "action": "Keep the correction deferred or revert it if regression evidence fails.", + "triggers": ["Regression evidence fails or compatibility review finds an unsupported impact."] + }, + "schema_version": "1.0", + "scope": { + "aesthetic": false, + "kind": "normative_correctness", + "summary": "Demonstrate the adoption-count bypass without waiving regression or lifecycle duties." + }, + "stable_scope_decision": false, + "support": { + "capacity": "committed", + "status": "active" + }, + "transition": { + "from": "consumer_local", + "to": "shared_experimental" + } +} diff --git a/consumer-reference/fixtures/token-portability/invalid-cycle.json b/consumer-reference/fixtures/token-portability/invalid-cycle.json new file mode 100644 index 0000000..a92c451 --- /dev/null +++ b/consumer-reference/fixtures/token-portability/invalid-cycle.json @@ -0,0 +1,7 @@ +{ + "space": { + "$type": "dimension", + "first": { "$value": "{space.second}" }, + "second": { "$value": "{space.first}" } + } +} diff --git a/consumer-reference/fixtures/token-portability/invalid-dangling-alias.json b/consumer-reference/fixtures/token-portability/invalid-dangling-alias.json new file mode 100644 index 0000000..4b3ca97 --- /dev/null +++ b/consumer-reference/fixtures/token-portability/invalid-dangling-alias.json @@ -0,0 +1,6 @@ +{ + "space": { + "$type": "dimension", + "large": { "$value": "{space.missing}" } + } +} diff --git a/consumer-reference/fixtures/token-portability/invalid-extends.json b/consumer-reference/fixtures/token-portability/invalid-extends.json new file mode 100644 index 0000000..103c5a8 --- /dev/null +++ b/consumer-reference/fixtures/token-portability/invalid-extends.json @@ -0,0 +1,4 @@ +{ + "base": { "$type": "dimension", "small": { "$value": { "value": 4, "unit": "px" } } }, + "derived": { "$extends": "{base}", "large": { "$type": "dimension", "$value": { "value": 8, "unit": "px" } } } +} diff --git a/consumer-reference/fixtures/token-portability/invalid-json-pointer.json b/consumer-reference/fixtures/token-portability/invalid-json-pointer.json new file mode 100644 index 0000000..4fd7dca --- /dev/null +++ b/consumer-reference/fixtures/token-portability/invalid-json-pointer.json @@ -0,0 +1,7 @@ +{ + "color": { + "$type": "color", + "base": { "$value": { "colorSpace": "srgb", "components": [0, 0, 0] } }, + "linked": { "$ref": "#/color/base" } + } +} diff --git a/consumer-reference/fixtures/token-portability/invalid-resolver.json b/consumer-reference/fixtures/token-portability/invalid-resolver.json new file mode 100644 index 0000000..843ad17 --- /dev/null +++ b/consumer-reference/fixtures/token-portability/invalid-resolver.json @@ -0,0 +1,9 @@ +{ + "themes": { + "dark": { "resolver": { "mode": "dark" } } + }, + "color": { + "$type": "color", + "base": { "$value": { "colorSpace": "srgb", "components": [0, 0, 0] }, "$extensions": { "modifier": "dark" } } + } +} diff --git a/consumer-reference/fixtures/token-portability/invalid-type-mismatch.json b/consumer-reference/fixtures/token-portability/invalid-type-mismatch.json new file mode 100644 index 0000000..73e7979 --- /dev/null +++ b/consumer-reference/fixtures/token-portability/invalid-type-mismatch.json @@ -0,0 +1,10 @@ +{ + "color": { + "$type": "color", + "base": { "$value": { "colorSpace": "srgb", "components": [0, 0, 0] } } + }, + "space": { + "$type": "dimension", + "wrong": { "$value": "{color.base}" } + } +} diff --git a/consumer-reference/fixtures/token-portability/invalid-unknown-reserved.json b/consumer-reference/fixtures/token-portability/invalid-unknown-reserved.json new file mode 100644 index 0000000..3a82ca6 --- /dev/null +++ b/consumer-reference/fixtures/token-portability/invalid-unknown-reserved.json @@ -0,0 +1,6 @@ +{ + "size": { + "$type": "dimension", + "small": { "$value": { "value": 4, "unit": "px" }, "$future": true } + } +} diff --git a/consumer-reference/fixtures/token-portability/invalid-untested-type.json b/consumer-reference/fixtures/token-portability/invalid-untested-type.json new file mode 100644 index 0000000..e859220 --- /dev/null +++ b/consumer-reference/fixtures/token-portability/invalid-untested-type.json @@ -0,0 +1,6 @@ +{ + "font": { + "$type": "fontFamily", + "body": { "$value": ["Inter", "sans-serif"] } + } +} diff --git a/consumer-reference/fixtures/token-portability/valid-border.json b/consumer-reference/fixtures/token-portability/valid-border.json new file mode 100644 index 0000000..01e1fab --- /dev/null +++ b/consumer-reference/fixtures/token-portability/valid-border.json @@ -0,0 +1,14 @@ +{ + "color": { + "$type": "color", + "line": { "$value": { "colorSpace": "srgb", "components": [0.2, 0.2, 0.2] } } + }, + "size": { + "$type": "dimension", + "line": { "$value": { "value": 1, "unit": "px" } } + }, + "border": { + "$type": "border", + "default": { "$value": { "color": "{color.line}", "style": "solid", "width": "{size.line}" } } + } +} diff --git a/consumer-reference/fixtures/token-portability/valid-reference.json b/consumer-reference/fixtures/token-portability/valid-reference.json new file mode 100644 index 0000000..837c7c3 --- /dev/null +++ b/consumer-reference/fixtures/token-portability/valid-reference.json @@ -0,0 +1,32 @@ +{ + "color": { + "$type": "color", + "accent": { + "$value": { + "colorSpace": "srgb", + "components": [0.125, 0.375, 0.875], + "alpha": 1 + } + }, + "action": { "$value": "{color.accent}" } + }, + "duration": { + "$type": "duration", + "quick": { "$value": { "value": 120, "unit": "ms" } } + }, + "space": { + "$type": "dimension", + "small": { "$value": { "value": 8, "unit": "px" } }, + "control": { "$value": "{space.small}" } + }, + "border": { + "$type": "border", + "focus": { + "$value": { + "color": "{color.action}", + "style": "solid", + "width": "{space.control}" + } + } + } +} diff --git a/consumer-reference/fixtures/token-portability/valid-whole-alias.json b/consumer-reference/fixtures/token-portability/valid-whole-alias.json new file mode 100644 index 0000000..c4609e8 --- /dev/null +++ b/consumer-reference/fixtures/token-portability/valid-whole-alias.json @@ -0,0 +1,7 @@ +{ + "color": { + "$type": "color", + "base": { "$value": { "colorSpace": "srgb", "components": [0.1, 0.2, 0.3] } }, + "linked": { "$value": "{color.base}" } + } +} diff --git a/consumer-reference/generated/manifest.json b/consumer-reference/generated/manifest.json new file mode 100644 index 0000000..d75f19e --- /dev/null +++ b/consumer-reference/generated/manifest.json @@ -0,0 +1,40 @@ +{ + "adapter": { + "name": "style-dictionary", + "version": "5.5.0" + }, + "declarations": [ + { + "name": "--color-accent", + "value": "#2060df" + }, + { + "name": "--duration-quick", + "value": "120ms" + }, + { + "name": "--space-small", + "value": "8px" + }, + { + "name": "--color-action", + "value": "var(--color-accent)" + }, + { + "name": "--space-control", + "value": "var(--space-small)" + }, + { + "name": "--border-focus", + "value": "var(--space-control) solid var(--color-action)" + } + ], + "inputHash": "sha256:660f1b752eccd4045af6ed0ae37f17f1eb436aec95302bf161687d9fa5e79a5f", + "outputCount": 6, + "outputFile": "tokens.css", + "outputHash": "sha256:a479ba780046bb5c1c393766ee47292049ca0515210c495081ef8cb3853f52a4", + "schemaVersion": "1.0", + "sourceCount": 6, + "sourceFile": "../fixtures/token-portability/valid-reference.json", + "warnings": [] +} diff --git a/consumer-reference/generated/tokens.css b/consumer-reference/generated/tokens.css new file mode 100644 index 0000000..6dcc19b --- /dev/null +++ b/consumer-reference/generated/tokens.css @@ -0,0 +1,12 @@ +/** + * Do not edit directly, this file was auto-generated. + */ + +:root { + --color-accent: #2060df; + --duration-quick: 120ms; + --space-small: 8px; + --color-action: var(--color-accent); + --space-control: var(--space-small); + --border-focus: var(--space-control) solid var(--color-action); +} diff --git a/consumer-reference/index.md b/consumer-reference/index.md index 540718d..3f7d38b 100644 --- a/consumer-reference/index.md +++ b/consumer-reference/index.md @@ -13,10 +13,17 @@ Out of scope: profiles, themes, components, browser runtime, decorative styling, ## Documents - [Receiver contract](contract.md) - Required handoff shape, record-path boundary, lifecycle fields, ownership truth, and dependency direction. +- [Consumer migration conformance schema](schema/consumer-conformance-record.schema.json) - Closed consumer-local record with thirteen explicit migration dimensions, runtime scenarios, adoption mappings, and optional page evidence. +- [Page evidence session schema](schema/page-evidence-session.schema.json) and [manifest schema](schema/page-evidence-manifest.schema.json) - Source-bound capture lifecycle for an applicable migration page. - [Consumer reference item schema](schema/item.schema.json) - Machine-readable shape for the current receiver item. +- [Promotion RFC schema](schema/promotion-rfc.schema.json) - Closed JSON shape for deferred or rejected invariant-promotion proposals. +- [Shared-experimental promotion policy](policies/shared-experimental.json) - Count scope, normative bypass, lifecycle, and zero-promotion claim boundary. +- [Chromium calibration schema](schema/calibration-record.schema.json) - Immutable environment, repetition, and evidence-cardinality contract for the proposed nonblocking sentinel. +- [Chromium baseline manifest schema](schema/baseline-manifest.schema.json) - Exact proposed source, baseline, platform, and calibration routing contract. +- `baselines/` - Proposed Chromium geometry baseline metadata and a pending committed-CI calibration record; these files do not constitute baseline-owner approval. - `fixtures/` - Related validator fixtures; they are not adopters or product references. ## IA Navigation Parent: [StyleGallery](../index.md). -Next: [Consumer Reference Receiver Contract](contract.md). +Next: [Consumer Migration Readiness](../design-engineering/consumer-migration-readiness.md). diff --git a/consumer-reference/policies/shared-experimental.json b/consumer-reference/policies/shared-experimental.json new file mode 100644 index 0000000..d601f35 --- /dev/null +++ b/consumer-reference/policies/shared-experimental.json @@ -0,0 +1,37 @@ +{ + "artifact_mode": "schema_only", + "boundaries": { + "excluded_content": ["component_skin", "imagery", "motion_character", "palette", "typography"], + "promoted_content": ["claim_scoped_evidence", "invariant_behavior", "invariant_semantics"] + }, + "claims": { + "adopter_attestations": 0, + "promotion_occurred": false + }, + "gateway": { + "applies_to": "invariant_eligibility_only", + "minimum_independent_consumers": 2, + "stable_numeric_threshold": null, + "transition": { + "from": "consumer_local", + "to": "shared_experimental" + } + }, + "id": "shared-experimental-policy", + "lifecycle": { + "failure_actions": ["deprecate_with_migration", "maintain", "restrict", "rollback"], + "stable_relabel": "forbidden" + }, + "normative_bypass": { + "categories": ["accessibility", "correctness", "security", "standards"], + "retains": ["compatibility", "deprecation", "migration", "owner_acceptance", "regression_proof", "rollback", "scope_control", "support_capacity"], + "waives": ["adoption_count"] + }, + "owner": { + "enforcement": "placeholder", + "name": "Repository governance owner" + }, + "review_independence": "single_account", + "schema_version": "1.0", + "status": "active" +} diff --git a/consumer-reference/schema/ax-evidence.schema.json b/consumer-reference/schema/ax-evidence.schema.json new file mode 100644 index 0000000..8db8554 --- /dev/null +++ b/consumer-reference/schema/ax-evidence.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stylegallery.local/consumer-reference/schema/ax-evidence.schema.json", + "title": "Closed AX capture artifact", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "channel", "capture_session", "captured_at", "profile_id", "scenario_id", "semantic_mode", "name", "role", "properties"], + "properties": { + "schema_version": { "const": "1.0" }, + "channel": { "const": "ax" }, + "capture_session": { "$ref": "https://stylegallery.local/consumer-reference/schema/capture-session.schema.json#/$defs/session_link" }, + "captured_at": { "type": "string", "format": "date-time" }, + "profile_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "scenario_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "semantic_mode": { "enum": ["action", "toggle", "disclosure", "switch"] }, + "name": { "type": "string", "minLength": 1 }, + "role": { "enum": ["button", "link", "switch"] }, + "properties": { + "type": "object", + "additionalProperties": false, + "properties": { + "busy": { "type": "boolean" }, + "disabled": { "type": "boolean" }, + "expanded": { "type": "boolean" }, + "focused": { "type": "boolean" }, + "pressed": { "type": "boolean" } + } + } + } +} diff --git a/consumer-reference/schema/baseline-manifest.schema.json b/consumer-reference/schema/baseline-manifest.schema.json new file mode 100644 index 0000000..2511ead --- /dev/null +++ b/consumer-reference/schema/baseline-manifest.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stylegallery.local/consumer-reference/schema/baseline-manifest.schema.json", + "title": "Proposed Chromium baseline manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "status", "baseline_owner_approval", "platform", "architecture", "container_image", "node", "os", "playwright", "browser_revision", "browser_version", "viewport", "source", "baseline", "calibration"], + "properties": { + "schema_version": { "const": "1.0" }, + "status": { "const": "proposed" }, + "baseline_owner_approval": { "const": "pending" }, + "platform": { "const": "linux/amd64" }, + "architecture": { "const": "amd64" }, + "container_image": { "const": "mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a" }, + "node": { "const": "22" }, + "os": { "const": "Ubuntu 24.04" }, + "playwright": { "const": "1.61.0" }, + "browser_revision": { "const": "1228" }, + "browser_version": { "const": "149.0.7827.55" }, + "viewport": { + "type": "object", + "additionalProperties": false, + "required": ["width", "height"], + "properties": { "width": { "const": 1024 }, "height": { "const": 768 } } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": { "const": "tests/helpers/render-consumer-reference.mjs" }, + "sha256": { "const": "88802a948909d5e40470be6b5481766ce2de498e59c053ac68af370b46e72ca9" } + } + }, + "baseline": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": { "const": "tests/snapshots/consumer-reference-card-grid.png" }, + "sha256": { "const": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837" } + } + }, + "calibration": { + "type": "object", + "additionalProperties": false, + "required": ["record", "required_runs", "workflow_artifact_name"], + "properties": { + "record": { "const": "consumer-reference/baselines/calibration.json" }, + "required_runs": { "const": 20 }, + "workflow_artifact_name": { "const": "chromium-sentinel-calibration-${run_id}-${sha}" } + } + } + } +} diff --git a/consumer-reference/schema/calibration-record.schema.json b/consumer-reference/schema/calibration-record.schema.json new file mode 100644 index 0000000..382c375 --- /dev/null +++ b/consumer-reference/schema/calibration-record.schema.json @@ -0,0 +1,190 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stylegallery.local/consumer-reference/schema/calibration-record.schema.json", + "title": "Chromium sentinel calibration record", + "$comment": "This schema owns recursive shape and exact scalar identity. scripts/baseline-contract.mjs additionally enforces committed_ci tuple correlation and cross-run DOM/AX/hash equality.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "status", "baseline_owner_approval", "required_runs", "committed_ci", "environment", "reference", "runs"], + "properties": { + "schema_version": { "const": "1.0" }, + "status": { "enum": ["awaiting_committed_ci", "awaiting_external_verification", "completed"] }, + "baseline_owner_approval": { "const": "pending" }, + "required_runs": { "const": 20 }, + "committed_ci": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["artifact_name", "checkout_sha", "execution_repository", "external_verification", "head_sha", "raw_evidence_sha256", "repository", "run_attempt", "run_id", "sha", "workflow"], + "properties": { + "run_id": { "type": "string", "pattern": "^[0-9]+$" }, + "run_attempt": { "type": "string", "pattern": "^[0-9]+$" }, + "sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "checkout_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "execution_repository": { + "description": "Actual GitHub Actions repository, limited to the canonical upstream or the explicit calibration fork.", + "enum": ["ark-jo/StyleGallery", "changeroa/StyleGallery"] + }, + "external_verification": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["artifact", "repository_relationship", "source", "verified_at"], + "properties": { + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["api_digest", "expires_at", "id", "name", "size_in_bytes"], + "properties": { + "api_digest": { "const": "sha256:3f11a517b447e1b5a1da17d9ee66ba2dd947fc8a0a482c447556ef00652e6074" }, + "expires_at": { "const": "2026-07-27T15:01:36Z" }, + "id": { "const": "8283099324" }, + "name": { "const": "chromium-sentinel-calibration-29260372260-18229be570766d3b42f5600955120bfcba690b76" }, + "size_in_bytes": { "const": 70310 } + } + }, + "repository_relationship": { + "type": "object", + "additionalProperties": false, + "required": ["execution_is_fork", "parent", "source"], + "properties": { + "execution_is_fork": { "const": true }, + "parent": { "const": "changeroa/StyleGallery" }, + "source": { "const": "changeroa/StyleGallery" } + } + }, + "source": { "const": "https://github.com/ark-jo/StyleGallery/actions/runs/29260372260" }, + "verified_at": { "const": "2026-07-13T15:04:41Z" } + } + } + ] + }, + "head_sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "raw_evidence_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "repository": { + "description": "Canonical upstream repository, independent of the GitHub Actions execution repository.", + "const": "changeroa/StyleGallery" + }, + "workflow": { "const": ".github/workflows/validate.yml" }, + "artifact_name": { "type": "string", "pattern": "^chromium-sentinel-calibration-[0-9]+-[0-9a-f]{40}$" } + } + } + ] + }, + "environment": { + "type": "object", + "additionalProperties": false, + "required": ["platform", "architecture", "container_image", "node", "os", "playwright", "browser_revision", "browser_version", "viewport"], + "properties": { + "platform": { "const": "linux/amd64" }, + "architecture": { "const": "amd64" }, + "container_image": { "const": "mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a" }, + "node": { "const": "22" }, + "os": { "const": "Ubuntu 24.04" }, + "playwright": { "const": "1.61.0" }, + "browser_revision": { "const": "1228" }, + "browser_version": { "const": "149.0.7827.55" }, + "viewport": { + "type": "object", + "additionalProperties": false, + "required": ["width", "height"], + "properties": { "width": { "const": 1024 }, "height": { "const": 768 } } + } + } + }, + "reference": { + "type": "object", + "additionalProperties": false, + "required": ["source", "baseline"], + "properties": { + "source": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": { "const": "tests/helpers/render-consumer-reference.mjs" }, + "sha256": { "const": "88802a948909d5e40470be6b5481766ce2de498e59c053ac68af370b46e72ca9" } + } + }, + "baseline": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": { "const": "tests/snapshots/consumer-reference-card-grid.png" }, + "sha256": { "const": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837" } + } + } + } + }, + "runs": { + "type": "array", + "maxItems": 20, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["run", "completed", "architecture", "png_sha256", "dom_sha256", "ax_sha256", "metadata_sha256", "screenshot_diff_pixels"], + "properties": { + "run": { "type": "integer", "minimum": 1, "maximum": 20 }, + "completed": { "const": true }, + "architecture": { "const": "amd64" }, + "png_sha256": { "const": "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837" }, + "dom_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "ax_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "metadata_sha256": { "const": "43a98d03b473e29c78ba2d359cf5f39346288d55564e5bfe65bc4a4fd78ebc2c" }, + "screenshot_diff_pixels": { "const": 0 } + } + } + } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "awaiting_committed_ci" } } }, + "then": { "properties": { "committed_ci": { "type": "null" }, "runs": { "maxItems": 0 } } }, + "else": { + "properties": { + "committed_ci": { "type": "object" }, + "runs": { + "minItems": 20, + "maxItems": 20, + "allOf": [ + { "contains": { "required": ["run"], "properties": { "run": { "const": 1 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 2 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 3 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 4 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 5 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 6 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 7 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 8 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 9 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 10 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 11 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 12 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 13 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 14 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 15 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 16 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 17 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 18 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 19 } } }, "minContains": 1, "maxContains": 1 }, + { "contains": { "required": ["run"], "properties": { "run": { "const": 20 } } }, "minContains": 1, "maxContains": 1 } + ] + } + } + } + }, + { + "if": { "properties": { "status": { "const": "completed" } } }, + "then": { "properties": { "committed_ci": { "properties": { "external_verification": { "type": "object" } } } } }, + "else": { + "if": { "properties": { "status": { "const": "awaiting_external_verification" } } }, + "then": { "properties": { "committed_ci": { "properties": { "external_verification": { "type": "null" } } } } } + } + } + ] +} diff --git a/consumer-reference/schema/capture-session.schema.json b/consumer-reference/schema/capture-session.schema.json new file mode 100644 index 0000000..47327fa --- /dev/null +++ b/consumer-reference/schema/capture-session.schema.json @@ -0,0 +1,161 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stylegallery.local/consumer-reference/schema/capture-session.schema.json", + "title": "Component-state capture session", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "record_kind", "session_id", "nonce", "started_at", "repository", "revision", "branch", "attempt", "environment", "source", "intended"], + "properties": { + "schema_version": { "const": "1.0" }, + "record_kind": { "const": "component_state_capture_session" }, + "session_id": { "type": "string", "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" }, + "nonce": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "started_at": { "type": "string", "format": "date-time" }, + "repository": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "branch": { "type": "string", "minLength": 1 }, + "attempt": { "type": "integer", "minimum": 1 }, + "environment": { "$ref": "#/$defs/environment" }, + "source": { "$ref": "#/$defs/source_manifest" }, + "intended": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["profile_name", "profile_id", "scenarios"], + "properties": { + "profile_name": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "profile_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "scenarios": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "channels"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "channels": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["visual", "dom", "ax"] } } + } + } + } + } + } + } + }, + "$defs": { + "environment": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "platform", "node", "browser", "browser_revision", "playwright", "lockfile_sha256", "container_image", "viewport"], + "properties": { + "kind": { "const": "browser" }, + "platform": { "type": "string", "minLength": 1 }, + "node": { "type": "string", "pattern": "^v\\d+\\.\\d+\\.\\d+$" }, + "browser": { "type": "string", "minLength": 1 }, + "browser_revision": { "type": "string", "minLength": 1 }, + "playwright": { "type": "string", "minLength": 1 }, + "lockfile_sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "container_image": { "type": "string", "minLength": 1 }, + "viewport": { "type": "string", "pattern": "^\\d+x\\d+$" } + } + }, + "session_link": { + "type": "object", + "additionalProperties": false, + "required": ["session_id", "nonce", "receipt_sha256", "started_at", "revision", "branch", "attempt", "environment", "source"], + "properties": { + "session_id": { "type": "string", "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" }, + "nonce": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "receipt_sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "started_at": { "type": "string", "format": "date-time" }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "branch": { "type": "string", "minLength": 1 }, + "attempt": { "type": "integer", "minimum": 1 }, + "environment": { "$ref": "#/$defs/environment" }, + "source": { "$ref": "#/$defs/source_manifest" } + } + }, + "receipt_artifact": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "byte_length", "media_type"], + "properties": { + "path": { "const": "capture-session.json" }, + "sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "byte_length": { "type": "integer", "minimum": 1 }, + "media_type": { "const": "application/json" } + } + }, + "completed_session": { + "type": "object", + "additionalProperties": false, + "required": ["session_id", "nonce", "receipt_sha256", "receipt", "started_at", "completed_at", "repository", "revision", "branch", "attempt", "environment", "source", "intended"], + "properties": { + "session_id": { "type": "string", "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" }, + "nonce": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "receipt_sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "receipt": { "$ref": "#/$defs/receipt_artifact" }, + "started_at": { "type": "string", "format": "date-time" }, + "completed_at": { "type": "string", "format": "date-time" }, + "repository": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "branch": { "type": "string", "minLength": 1 }, + "attempt": { "type": "integer", "minimum": 1 }, + "environment": { "$ref": "#/$defs/environment" }, + "source": { "$ref": "#/$defs/source_manifest" }, + "intended": { "$ref": "#/$defs/intended" } + } + }, + "source_manifest": { + "type": "object", + "additionalProperties": false, + "required": ["sha256", "files"], + "properties": { + "sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "files": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "byte_length"], + "properties": { + "path": { "type": "string", "pattern": "^(?:(?:consumer-reference|profiles|scripts|tests)/[^\\\\?#]+|package(?:-lock)?\\.json|playwright\\.config\\.mjs)$" }, + "sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "byte_length": { "type": "integer", "minimum": 1 } + } + } + } + } + }, + "intended": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["profile_name", "profile_id", "scenarios"], + "properties": { + "profile_name": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "profile_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "scenarios": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "channels"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "channels": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["visual", "dom", "ax"] } } + } + } + } + } + } + } + } +} diff --git a/consumer-reference/schema/component-state.schema.json b/consumer-reference/schema/component-state.schema.json new file mode 100644 index 0000000..e43764e --- /dev/null +++ b/consumer-reference/schema/component-state.schema.json @@ -0,0 +1,150 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stylegallery.local/consumer-reference/schema/component-state.schema.json", + "title": "Consumer component and compound-state records", + "oneOf": [ + { "$ref": "#/$defs/component" }, + { "$ref": "#/$defs/stateSet" } + ], + "$defs": { + "applicability": { + "type": "object", + "additionalProperties": false, + "required": ["name", "status"], + "properties": { + "name": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "status": { "enum": ["required", "conditional", "not_applicable", "prohibited"] }, + "condition": { "type": "string", "minLength": 1 }, + "resolution": { "enum": ["required", "not_applicable", "prohibited"] }, + "reason": { "type": "string", "minLength": 10, "pattern": "^\\S+(?:\\s+\\S+){2,}\\s*[.!?]$" }, + "values": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": ["string", "boolean"] } } + } + }, + "component": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "record_kind", "component_id", "profile_id", "anatomy", "semantic_modes", "versions"], + "properties": { + "schema_version": { "const": "1.0" }, + "record_kind": { "const": "component" }, + "component_id": { "const": "button" }, + "profile_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "anatomy": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" } }, + "semantic_modes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "element", "role", "keys", "aria_states", "activation"], + "properties": { + "id": { "enum": ["action", "toggle", "disclosure", "switch"] }, + "element": { "const": "button" }, + "role": { "enum": ["button", "switch"] }, + "keys": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["Enter", "Space"] } }, + "aria_states": { "type": "array", "items": { "$ref": "#/$defs/applicability" } }, + "activation": { "enum": ["invoke", "toggle", "control"] } + } + } + }, + "versions": { + "type": "object", + "additionalProperties": false, + "required": ["design_revision", "package_version", "api_version", "delivery_channel", "lifecycle"], + "properties": { + "design_revision": { "type": "string", "pattern": "^\\d{4}\\.\\d{2}\\.\\d{2}$" }, + "package_version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[a-z0-9.-]+)?$" }, + "api_version": { "type": "string", "pattern": "^\\d+\\.\\d+$" }, + "delivery_channel": { "enum": ["preview", "stable"] }, + "lifecycle": { "enum": ["experimental", "stable", "deprecated"] } + } + } + } + }, + "stateSet": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "record_kind", "component_id", "profile_id", "visual_environments", "scenarios"], + "properties": { + "schema_version": { "const": "1.0" }, + "record_kind": { "const": "state_set" }, + "component_id": { "const": "button" }, + "profile_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "visual_environments": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "browser", "browser_revision", "container_image", "platform", "playwright", "viewport"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "browser": { "type": "string", "minLength": 1 }, + "browser_revision": { "type": "string", "minLength": 1 }, + "container_image": { "type": "string", "minLength": 1 }, + "platform": { "enum": ["darwin/arm64", "linux/amd64"] }, + "playwright": { "const": "1.61.0" }, + "viewport": { "const": "1024x768" } + } + } + }, + "scenarios": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "semantic_mode", "states", "aria", "expected"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "semantic_mode": { "enum": ["action", "toggle", "disclosure", "switch"] }, + "states": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["focus", "pressed", "expanded", "loading", "busy", "disabled", "invalid", "selected", "current", "reduced_motion"] } }, + "aria": { + "type": "object", + "additionalProperties": false, + "properties": { + "pressed": { "enum": ["true", "false"] }, + "expanded": { "enum": ["true", "false"] }, + "busy": { "enum": ["true", "false"] }, + "disabled": { "enum": ["true", "false"] }, + "checked": { "enum": ["true", "false"] } + } + }, + "expected": { + "type": "object", + "additionalProperties": false, + "required": ["visual", "visual_image", "dom", "ax", "activation"], + "properties": { + "visual": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "enum": ["ready", "focus", "pressed", "expanded", "loading", "busy", "disabled"] } + }, + "visual_image": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["environment_id", "sha256", "width", "height"], + "properties": { + "environment_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "width": { "type": "integer", "minimum": 44 }, + "height": { "type": "integer", "minimum": 44 } + } + } + }, + "dom": { "type": "object", "additionalProperties": { "type": "string" } }, + "ax": { "type": "object", "additionalProperties": { "type": ["string", "boolean"] } }, + "activation": { "enum": ["allowed", "suppressed"] } + } + } + } + } + } + } + } + } +} diff --git a/consumer-reference/schema/consumer-conformance-record.schema.json b/consumer-reference/schema/consumer-conformance-record.schema.json new file mode 100644 index 0000000..8bd39ec --- /dev/null +++ b/consumer-reference/schema/consumer-conformance-record.schema.json @@ -0,0 +1,365 @@ +{ + "$defs": { + "adoptionMapping": { + "additionalProperties": false, + "properties": { + "consumer_target": { "$ref": "#/$defs/consumerTarget" }, + "debt": { + "items": { "$ref": "#/$defs/debt" }, + "type": "array" + }, + "deviations": { + "items": { "$ref": "#/$defs/deviation" }, + "type": "array" + }, + "local_decision": { "$ref": "#/$defs/localDecision" }, + "scenario_ids": { "$ref": "#/$defs/scenarioIds" }, + "stylegallery": { "$ref": "#/$defs/stylegallerySource" } + }, + "required": [ + "consumer_target", + "debt", + "deviations", + "local_decision", + "scenario_ids", + "stylegallery" + ], + "type": "object" + }, + "consumerTarget": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "identity": { + "pattern": "^[A-Z][A-Za-z0-9]*(?:[._/-][A-Za-z0-9_-]+)*$", + "type": "string" + }, + "kind": { "const": "component" } + }, + "required": ["identity", "kind"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "identity": { + "pattern": "^(?:[.#][A-Za-z_][A-Za-z0-9_-]*|\\[[A-Za-z_:][-A-Za-z0-9_:.]*(?:=[^\\]\\r\\n]+)?\\])$", + "type": "string" + }, + "kind": { "const": "selector" } + }, + "required": ["identity", "kind"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "identity": { "$ref": "#/$defs/repositoryPath" }, + "kind": { "const": "module" } + }, + "required": ["identity", "kind"], + "type": "object" + } + ] + }, + "debt": { + "additionalProperties": false, + "properties": { + "owner": { "minLength": 1, "type": "string" }, + "review_by": { "format": "date-time", "type": "string" }, + "review_trigger": { "$ref": "#/$defs/sentence" }, + "summary": { "$ref": "#/$defs/sentence" } + }, + "required": ["owner", "review_by", "review_trigger", "summary"], + "type": "object" + }, + "deviation": { + "additionalProperties": false, + "properties": { + "reason": { "$ref": "#/$defs/sentence" }, + "summary": { "$ref": "#/$defs/sentence" } + }, + "required": ["reason", "summary"], + "type": "object" + }, + "dimension": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "scenario_ids": { "$ref": "#/$defs/scenarioIds" }, + "status": { "const": "applicable" } + }, + "required": ["scenario_ids", "status"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "reason": { "$ref": "#/$defs/sentence" }, + "status": { "const": "not_applicable" } + }, + "required": ["reason", "status"], + "type": "object" + } + ] + }, + "jsonPath": { + "pattern": "^(?!/)(?!.*//)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?![A-Za-z][A-Za-z0-9+.-]*:)(?!.*\\/$)[^\\\\?#]+\\.json$", + "type": "string" + }, + "localDecision": { + "additionalProperties": false, + "properties": { + "decision": { "$ref": "#/$defs/sentence" }, + "identity": { + "minLength": 1, + "pattern": "^\\S(?:.*\\S)?$", + "type": "string" + }, + "kind": { "enum": ["token", "unit"] } + }, + "required": ["decision", "identity", "kind"], + "type": "object" + }, + "pageEvidence": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "manifest": { "$ref": "#/$defs/jsonPath" }, + "status": { "const": "applicable" } + }, + "required": ["manifest", "status"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "reason": { "$ref": "#/$defs/sentence" }, + "status": { "const": "not_applicable" } + }, + "required": ["reason", "status"], + "type": "object" + } + ] + }, + "repositoryPath": { + "pattern": "^(?!/)(?!.*//)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?![A-Za-z][A-Za-z0-9+.-]*:)(?!.*\\/$)[^\\\\?#]+$", + "type": "string" + }, + "argv": { + "items": { "minLength": 1, "type": "string" }, + "maxItems": 64, + "minItems": 1, + "type": "array" + }, + "runtimeResult": { + "additionalProperties": false, + "properties": { + "argv": { "$ref": "#/$defs/argv" }, + "evidence_method": { "enum": ["unit", "integration", "browser"] }, + "exit_code": { "const": 0 }, + "record_kind": { "const": "consumer_migration_scenario_result" }, + "recorded_at": { "format": "date-time", "type": "string" }, + "repository": { "minLength": 3, "pattern": "^\\S+/\\S+$", "type": "string" }, + "revision": { "pattern": "^[a-f0-9]{40}$", "type": "string" }, + "run_id": { "minLength": 1, "pattern": "^\\S+$", "type": "string" }, + "scenario_id": { "$ref": "#/$defs/slug" }, + "schema_version": { "const": "1.0" }, + "session_id": { "minLength": 1, "pattern": "^\\S+$", "type": "string" }, + "source_digest": { "$ref": "#/$defs/sha256" }, + "status": { "const": "passed" } + }, + "required": [ + "argv", + "evidence_method", + "exit_code", + "record_kind", + "recorded_at", + "repository", + "revision", + "run_id", + "scenario_id", + "schema_version", + "session_id", + "source_digest", + "status" + ], + "type": "object" + }, + "scenario": { + "additionalProperties": false, + "properties": { + "assertions": { + "items": { "minLength": 1, "type": "string" }, + "minItems": 1, + "type": "array" + }, + "argv": { "$ref": "#/$defs/argv" }, + "evidence_method": { "enum": ["unit", "integration", "browser"] }, + "exit_code": { "const": 0 }, + "id": { "$ref": "#/$defs/slug" }, + "observable_actions": { + "items": { "minLength": 1, "type": "string" }, + "minItems": 1, + "type": "array" + }, + "result_artifact": { "$ref": "#/$defs/jsonPath" }, + "run_id": { "minLength": 1, "pattern": "^\\S+$", "type": "string" }, + "session_id": { "minLength": 1, "pattern": "^\\S+$", "type": "string" }, + "source_digest": { "$ref": "#/$defs/sha256" } + }, + "required": [ + "assertions", + "argv", + "evidence_method", + "exit_code", + "id", + "observable_actions", + "result_artifact", + "run_id", + "session_id", + "source_digest" + ], + "type": "object" + }, + "scenarioIds": { + "items": { "$ref": "#/$defs/slug" }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "sentence": { + "minLength": 10, + "pattern": "^\\S+(?:\\s+\\S+){2,}\\s*[.!?]$", + "type": "string" + }, + "sha256": { + "pattern": "^[a-f0-9]{64}$", + "type": "string" + }, + "slug": { + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "stylegallerySource": { + "additionalProperties": false, + "properties": { + "anchor": { + "pattern": "^#[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + }, + "path": { "$ref": "#/$defs/repositoryPath" }, + "revision": { + "pattern": "^[a-f0-9]{40}$", + "type": "string" + } + }, + "required": ["anchor", "path", "revision"], + "type": "object" + } + }, + "$id": "https://stylegallery.example/consumer-reference/schema/consumer-conformance-record.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "adoption_mappings": { + "items": { "$ref": "#/$defs/adoptionMapping" }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "claim_boundary": { + "additionalProperties": false, + "properties": { + "certifies_accessibility": { "const": false }, + "certifies_product_correctness": { "const": false }, + "scope": { "$ref": "#/$defs/sentence" } + }, + "required": ["certifies_accessibility", "certifies_product_correctness", "scope"], + "type": "object" + }, + "consumer": { + "additionalProperties": false, + "properties": { + "relevant_sources": { + "items": { "$ref": "#/$defs/repositoryPath" }, + "maxItems": 1024, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "repository": { + "minLength": 3, + "pattern": "^\\S+/\\S+$", + "type": "string" + }, + "revision": { + "pattern": "^[a-f0-9]{40}$", + "type": "string" + } + }, + "required": ["relevant_sources", "repository", "revision"], + "type": "object" + }, + "id": { "$ref": "#/$defs/slug" }, + "migration_dimensions": { + "additionalProperties": false, + "properties": { + "action_parity": { "$ref": "#/$defs/dimension" }, + "atomic_batch_behavior": { "$ref": "#/$defs/dimension" }, + "behavior_inventory": { "$ref": "#/$defs/dimension" }, + "contract_precedence": { "$ref": "#/$defs/dimension" }, + "defaults_tri_state_mapping": { "$ref": "#/$defs/dimension" }, + "direct_mutation": { "$ref": "#/$defs/dimension" }, + "exact_time_boundary": { "$ref": "#/$defs/dimension" }, + "field_parity": { "$ref": "#/$defs/dimension" }, + "indirect_mutation": { "$ref": "#/$defs/dimension" }, + "persistence_round_trip": { "$ref": "#/$defs/dimension" }, + "reset_boundary": { "$ref": "#/$defs/dimension" }, + "route_parity": { "$ref": "#/$defs/dimension" }, + "state_transitions": { "$ref": "#/$defs/dimension" } + }, + "required": [ + "action_parity", + "atomic_batch_behavior", + "behavior_inventory", + "contract_precedence", + "defaults_tri_state_mapping", + "direct_mutation", + "exact_time_boundary", + "field_parity", + "indirect_mutation", + "persistence_round_trip", + "reset_boundary", + "route_parity", + "state_transitions" + ], + "type": "object" + }, + "page_evidence": { "$ref": "#/$defs/pageEvidence" }, + "record_kind": { "const": "consumer_migration_conformance" }, + "scenarios": { + "items": { "$ref": "#/$defs/scenario" }, + "maxItems": 256, + "minItems": 1, + "type": "array" + }, + "schema_version": { "const": "1.0" } + }, + "required": [ + "adoption_mappings", + "claim_boundary", + "consumer", + "id", + "migration_dimensions", + "page_evidence", + "record_kind", + "scenarios", + "schema_version" + ], + "title": "Consumer migration conformance record", + "type": "object" +} diff --git a/consumer-reference/schema/dom-evidence.schema.json b/consumer-reference/schema/dom-evidence.schema.json new file mode 100644 index 0000000..f0c6b63 --- /dev/null +++ b/consumer-reference/schema/dom-evidence.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stylegallery.local/consumer-reference/schema/dom-evidence.schema.json", + "title": "Closed DOM capture artifact", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "channel", "capture_session", "captured_at", "profile_id", "scenario_id", "semantic_mode", "role", "active", "activation_key", "activation_count", "attributes", "visual_states"], + "properties": { + "schema_version": { "const": "1.0" }, + "channel": { "const": "dom" }, + "capture_session": { "$ref": "https://stylegallery.local/consumer-reference/schema/capture-session.schema.json#/$defs/session_link" }, + "captured_at": { "type": "string", "format": "date-time" }, + "profile_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "scenario_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "semantic_mode": { "enum": ["action", "toggle", "disclosure", "switch"] }, + "role": { "enum": ["button", "link", "switch"] }, + "active": { "type": "boolean" }, + "activation_key": { "enum": ["Enter", "Space"] }, + "activation_count": { "type": "integer", "minimum": 0 }, + "attributes": { + "type": "object", + "additionalProperties": false, + "properties": { + "aria-busy": { "enum": ["true", "false"] }, + "aria-disabled": { "enum": ["true", "false"] }, + "aria-expanded": { "enum": ["true", "false"] }, + "aria-pressed": { "enum": ["true", "false"] }, + "disabled": { "enum": ["true", "false"] } + } + }, + "visual_states": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "enum": ["ready", "focus", "pressed", "expanded", "loading", "busy", "disabled"] } + } + } +} diff --git a/consumer-reference/schema/evidence-record.schema.json b/consumer-reference/schema/evidence-record.schema.json new file mode 100644 index 0000000..b3824c9 --- /dev/null +++ b/consumer-reference/schema/evidence-record.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stylegallery.local/consumer-reference/schema/evidence-record.schema.json", + "title": "Claim-scoped consumer component evidence", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "component_id", "profile_id", "claim_boundary", "passes"], + "properties": { + "schema_version": { "const": "1.0" }, + "component_id": { "const": "button" }, + "profile_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "claim_boundary": { "const": "Scenario artifacts are evidence, not visual regression baselines or accessibility certification." }, + "passes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "scenario_id", "artifact", "recorded_at", "environment", "run", "session", "scope", "channel", "result"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "scenario_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "byte_length", "media_type"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "byte_length": { "type": "integer", "minimum": 1 }, + "media_type": { "enum": ["image/png", "application/json"] }, + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + }, + "allOf": [ + { + "if": { "properties": { "media_type": { "const": "image/png" } }, "required": ["media_type"] }, + "then": { "required": ["width", "height"] } + } + ] + }, + "recorded_at": { "type": "string", "format": "date-time" }, + "environment": { "$ref": "https://stylegallery.local/consumer-reference/schema/capture-session.schema.json#/$defs/environment" }, + "run": { + "type": "object", + "additionalProperties": false, + "required": ["id", "source", "repository", "revision", "attempt"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "source": { "enum": ["local", "github_actions"] }, + "repository": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "attempt": { "type": "integer", "minimum": 1 } + } + }, + "session": { "$ref": "https://stylegallery.local/consumer-reference/schema/capture-session.schema.json#/$defs/session_link" }, + "scope": { + "type": "object", + "additionalProperties": false, + "required": ["component", "semantic_mode", "state_set"], + "properties": { + "component": { "const": "button" }, + "semantic_mode": { "enum": ["action", "toggle", "disclosure", "switch"] }, + "state_set": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string" } } + } + }, + "channel": { "enum": ["static", "browser", "visual", "dom", "ax", "human"] }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["status", "observed"], + "properties": { + "status": { "const": "passed" }, + "observed": { "type": "string", "minLength": 1 } + } + } + } + } + } + } +} diff --git a/consumer-reference/schema/fixture-manifest.schema.json b/consumer-reference/schema/fixture-manifest.schema.json new file mode 100644 index 0000000..5dfef4c --- /dev/null +++ b/consumer-reference/schema/fixture-manifest.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stylegallery.local/consumer-reference/schema/fixture-manifest.schema.json", + "title": "Consumer component runtime fixture manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "fixture_id", "component_id", "profile_id", "component_record", "state_record", "scenarios"], + "properties": { + "schema_version": { "const": "1.0" }, + "fixture_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "component_id": { "const": "button" }, + "profile_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "component_record": { "const": "../components/button.component.json" }, + "state_record": { "const": "../states/button.states.json" }, + "scenarios": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "semantic_mode", "label", "activation_key", "required_channels"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "semantic_mode": { "enum": ["action", "toggle", "disclosure", "switch"] }, + "label": { "type": "string", "minLength": 1 }, + "activation_key": { "enum": ["Enter", "Space"] }, + "required_channels": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["static", "browser", "visual", "dom", "ax", "human"] } } + } + } + } + } +} diff --git a/consumer-reference/schema/item.schema.json b/consumer-reference/schema/item.schema.json index 1202c42..029ecb5 100644 --- a/consumer-reference/schema/item.schema.json +++ b/consumer-reference/schema/item.schema.json @@ -22,13 +22,85 @@ "required": ["maturity"] }, "then": { "required": ["removal_trigger", "replacement"] } + }, + { + "if": { "required": ["profile_kind"] }, + "then": { + "properties": { + "artifact_mode": { "const": "governed_local" }, + "default": { "const": false }, + "example_only": { "const": true }, + "maturity": { "const": "experimental" }, + "profile_kind": { "const": "governed_local" } + }, + "required": [ + "default", + "environment_assumptions", + "example_only", + "component_records", + "evidence_records", + "fixture_records", + "generated_records", + "layout_source_sha", + "local_foundations", + "profile_kind", + "related_fixture_set_id", + "selection", + "state_records", + "tokens" + ] + } } ], "properties": { "artifact_mode": { "enum": ["consumer_local", "external_consumer", "governed_local", "schema_only"] }, + "component_records": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "const": "components/button.component.json" } + }, + "default": { "const": false }, + "environment_assumptions": { + "additionalProperties": false, + "properties": { + "reset": { + "additionalProperties": false, + "properties": { + "body_margin": { "minLength": 1, "type": "string" }, + "box_sizing": { "minLength": 1, "type": "string" }, + "figure_margin": { "minLength": 1, "type": "string" } + }, + "required": ["body_margin", "box_sizing", "figure_margin"], + "type": "object" + }, + "user_agent_styles": { "minLength": 1, "type": "string" } + }, + "required": ["reset", "user_agent_styles"], + "type": "object" + }, + "example_only": { "const": true }, + "evidence_records": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "const": "evidence/button.evidence.json" } + }, "fixture_independence": { "const": "related" }, + "fixture_records": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "const": "fixtures/button.fixture.json" } + }, + "generated_records": { + "type": "array", + "minItems": 3, + "uniqueItems": true, + "items": { "enum": ["generated/state-matrix.md", "generated/keyboard-matrix.md", "generated/evidence-coverage.md"] } + }, "handoff": { "oneOf": [ { @@ -59,6 +131,8 @@ ] }, "id": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, + "layout_source_sha": { "const": "775430bbaf4ee208a642220f440f6926d79c90a3" }, + "local_foundations": { "const": "local-foundations.json" }, "maturity": { "enum": ["deprecated", "experimental", "stable"] }, "owner": { "additionalProperties": false, @@ -70,15 +144,33 @@ "type": "object" }, "removal_trigger": { "minLength": 1, "type": "string" }, + "profile_kind": { "const": "governed_local" }, + "related_fixture_set_id": { "minLength": 1, "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", "type": "string" }, "replacement": { "minLength": 1, "type": "string" }, "review_independence": { "const": "single_account" }, "schema_version": { "const": "1.0" }, + "selection": { + "additionalProperties": false, + "properties": { + "method": { "const": "profile_path" }, + "required": { "const": true } + }, + "required": ["method", "required"], + "type": "object" + }, "support": { "additionalProperties": false, "properties": { "status": { "enum": ["active", "ended"] } }, "required": ["status"], "type": "object" - } + }, + "state_records": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "const": "states/button.states.json" } + }, + "tokens": { "const": "tokens.dtcg.json" } }, "required": [ "artifact_mode", diff --git a/consumer-reference/schema/page-evidence-manifest.schema.json b/consumer-reference/schema/page-evidence-manifest.schema.json new file mode 100644 index 0000000..d987ee9 --- /dev/null +++ b/consumer-reference/schema/page-evidence-manifest.schema.json @@ -0,0 +1,216 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stylegallery.local/consumer-reference/schema/page-evidence-manifest.schema.json", + "title": "Closed consumer-local page evidence manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "record_kind", "claim_boundary", "completed_at", "review_by", "repository", "revision", "run", "session", "scenarios"], + "properties": { + "schema_version": { "const": "1.0" }, + "record_kind": { "const": "page_evidence_manifest" }, + "claim_boundary": { "const": "Page evidence is claim-scoped runtime evidence, not visual-regression approval or accessibility certification." }, + "completed_at": { "type": "string", "format": "date-time" }, + "review_by": { "type": "string", "format": "date-time" }, + "repository": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "run": { "$ref": "#/$defs/run" }, + "session": { "$ref": "https://stylegallery.local/consumer-reference/schema/page-evidence-session.schema.json#/$defs/completed_session" }, + "scenarios": { + "type": "array", + "maxItems": 256, + "minItems": 1, + "items": { "$ref": "#/$defs/manifest_scenario" } + } + }, + "$defs": { + "hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "identity": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" }, + "scenario_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "normalized_path": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?![A-Za-z]:)(?!.*\\\\)(?!.*[?#]).+$" }, + "run": { + "type": "object", + "additionalProperties": false, + "required": ["id", "source", "repository", "revision", "attempt", "status"], + "properties": { + "id": { "$ref": "#/$defs/identity" }, + "source": { "enum": ["local", "github_actions"] }, + "repository": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "attempt": { "type": "integer", "minimum": 1 }, + "status": { "const": "passed" } + } + }, + "semantic_environment": { + "type": "object", + "additionalProperties": false, + "required": ["browser", "browser_revision", "platform", "viewport", "page_scale_factor", "locale", "color_scheme", "reduced_motion"], + "properties": { + "browser": { "type": "string", "minLength": 1 }, + "browser_revision": { "type": "string", "minLength": 1 }, + "platform": { "type": "string", "minLength": 1 }, + "viewport": { + "type": "object", + "additionalProperties": false, + "required": ["width", "height"], + "properties": { + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + } + }, + "page_scale_factor": { "type": "number", "exclusiveMinimum": 0, "maximum": 10 }, + "locale": { "type": "string", "minLength": 1 }, + "color_scheme": { "enum": ["light", "dark", "no-preference"] }, + "reduced_motion": { "enum": ["reduce", "no-preference"] } + } + }, + "artifact_declaration": { + "type": "object", + "additionalProperties": false, + "required": ["path", "media_type"], + "properties": { + "path": { "$ref": "#/$defs/normalized_path" }, + "media_type": { "enum": ["application/json", "image/png", "text/plain"] } + } + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "byte_length", "media_type"], + "properties": { + "path": { "$ref": "#/$defs/normalized_path" }, + "sha256": { "$ref": "#/$defs/hash" }, + "byte_length": { "type": "integer", "minimum": 1 }, + "media_type": { "enum": ["application/json", "image/png", "text/plain"] }, + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + }, + "allOf": [ + { + "if": { "properties": { "media_type": { "const": "image/png" } }, "required": ["media_type"] }, + "then": { + "required": ["width", "height"], + "properties": { + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + } + } + } + ] + }, + "reuse": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "prior_manifest_sha256", "prior_session_id", "prior_run_id", "prior_scenario_id"], + "properties": { + "kind": { "const": "reused" }, + "prior_manifest_sha256": { "$ref": "#/$defs/hash" }, + "prior_session_id": { "$ref": "#/$defs/identity" }, + "prior_run_id": { "$ref": "#/$defs/identity" }, + "prior_scenario_id": { "$ref": "#/$defs/scenario_id" } + } + }, + "runner_result": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "record_kind", "session_id", "nonce", "receipt_sha256", "repository", "revision", "source_sha256", "run", "scenario_id", "recorded_at", "semantic_environment", "status", "evidence"], + "properties": { + "schema_version": { "const": "1.0" }, + "record_kind": { "const": "page_evidence_runner_result" }, + "session_id": { "$ref": "#/$defs/identity" }, + "nonce": { "$ref": "#/$defs/hash" }, + "receipt_sha256": { "$ref": "#/$defs/hash" }, + "repository": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "source_sha256": { "$ref": "#/$defs/hash" }, + "run": { + "type": "object", + "additionalProperties": false, + "required": ["id", "source", "repository", "revision", "attempt"], + "properties": { + "id": { "$ref": "#/$defs/identity" }, + "source": { "enum": ["local", "github_actions"] }, + "repository": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "attempt": { "type": "integer", "minimum": 1 } + } + }, + "scenario_id": { "$ref": "#/$defs/scenario_id" }, + "recorded_at": { "type": "string", "format": "date-time" }, + "semantic_environment": { "$ref": "#/$defs/semantic_environment" }, + "status": { "enum": ["passed", "failed"] }, + "evidence": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "artifacts"], + "properties": { + "kind": { "const": "captured" }, + "artifacts": { "type": "array", "minItems": 1, "maxItems": 128, "items": { "$ref": "#/$defs/artifact_declaration" } } + } + }, + { "$ref": "#/$defs/reuse" }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "reason"], + "properties": { + "kind": { "const": "failed" }, + "reason": { "type": "string", "minLength": 1 } + } + } + ] + } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "passed" } }, "required": ["status"] }, + "then": { + "properties": { + "evidence": { + "type": "object", + "oneOf": [ + { "$ref": "#/$defs/reuse" }, + { "type": "object", "properties": { "kind": { "const": "captured" } }, "required": ["kind"] } + ] + } + } + } + }, + { + "if": { "properties": { "status": { "const": "failed" } }, "required": ["status"] }, + "then": { + "properties": { + "evidence": { "type": "object", "properties": { "kind": { "const": "failed" } }, "required": ["kind"] } + } + } + } + ] + }, + "manifest_scenario": { + "type": "object", + "additionalProperties": false, + "required": ["id", "recorded_at", "semantic_environment", "runner_result", "evidence"], + "properties": { + "id": { "$ref": "#/$defs/scenario_id" }, + "recorded_at": { "type": "string", "format": "date-time" }, + "semantic_environment": { "$ref": "#/$defs/semantic_environment" }, + "runner_result": { "$ref": "#/$defs/artifact" }, + "evidence": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "artifacts"], + "properties": { + "kind": { "const": "captured" }, + "artifacts": { "type": "array", "minItems": 1, "maxItems": 128, "items": { "$ref": "#/$defs/artifact" } } + } + }, + { "$ref": "#/$defs/reuse" } + ] + } + } + } + } +} diff --git a/consumer-reference/schema/page-evidence-session.schema.json b/consumer-reference/schema/page-evidence-session.schema.json new file mode 100644 index 0000000..923541f --- /dev/null +++ b/consumer-reference/schema/page-evidence-session.schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stylegallery.local/consumer-reference/schema/page-evidence-session.schema.json", + "title": "Consumer-local page evidence start receipt", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "record_kind", "session_id", "run_id", "nonce", "started_at", "repository", "revision", "branch", "attempt", "environment", "conformance_record", "source", "intended_scenario_ids"], + "properties": { + "schema_version": { "const": "1.0" }, + "record_kind": { "const": "page_evidence_session" }, + "session_id": { "$ref": "#/$defs/identity" }, + "run_id": { "$ref": "#/$defs/identity" }, + "nonce": { "$ref": "#/$defs/hash" }, + "started_at": { "type": "string", "format": "date-time" }, + "repository": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "branch": { "type": "string", "minLength": 1 }, + "attempt": { "type": "integer", "minimum": 1 }, + "environment": { "$ref": "#/$defs/producer_environment" }, + "conformance_record": { "$ref": "#/$defs/json_artifact" }, + "source": { "$ref": "#/$defs/source_manifest" }, + "intended_scenario_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/$defs/scenario_id" } + } + }, + "$defs": { + "hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "identity": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" }, + "scenario_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "normalized_path": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?![A-Za-z]:)(?!.*\\\\)(?!.*[?#]).+$" }, + "producer_environment": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "node", "platform", "producer"], + "properties": { + "kind": { "const": "browser_evidence" }, + "node": { "type": "string", "pattern": "^v\\d+\\.\\d+\\.\\d+$" }, + "platform": { "type": "string", "minLength": 1 }, + "producer": { "const": "stylegallery_page_evidence" } + } + }, + "file_artifact": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "byte_length"], + "properties": { + "path": { "$ref": "#/$defs/normalized_path" }, + "sha256": { "$ref": "#/$defs/hash" }, + "byte_length": { "type": "integer", "minimum": 1 } + } + }, + "json_artifact": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "byte_length", "media_type"], + "properties": { + "path": { "$ref": "#/$defs/normalized_path" }, + "sha256": { "$ref": "#/$defs/hash" }, + "byte_length": { "type": "integer", "minimum": 1 }, + "media_type": { "const": "application/json" } + } + }, + "source_manifest": { + "type": "object", + "additionalProperties": false, + "required": ["sha256", "files"], + "properties": { + "sha256": { "$ref": "#/$defs/hash" }, + "files": { + "type": "array", + "maxItems": 1024, + "minItems": 1, + "items": { "$ref": "#/$defs/file_artifact" } + } + } + }, + "receipt_artifact": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "byte_length", "media_type"], + "properties": { + "path": { "const": "page-evidence-session.json" }, + "sha256": { "$ref": "#/$defs/hash" }, + "byte_length": { "type": "integer", "minimum": 1 }, + "media_type": { "const": "application/json" } + } + }, + "completed_session": { + "type": "object", + "additionalProperties": false, + "required": ["session_id", "run_id", "nonce", "receipt_sha256", "receipt", "started_at", "completed_at", "repository", "revision", "branch", "attempt", "environment", "conformance_record", "source", "intended_scenario_ids"], + "properties": { + "session_id": { "$ref": "#/$defs/identity" }, + "run_id": { "$ref": "#/$defs/identity" }, + "nonce": { "$ref": "#/$defs/hash" }, + "receipt_sha256": { "$ref": "#/$defs/hash" }, + "receipt": { "$ref": "#/$defs/receipt_artifact" }, + "started_at": { "type": "string", "format": "date-time" }, + "completed_at": { "type": "string", "format": "date-time" }, + "repository": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "branch": { "type": "string", "minLength": 1 }, + "attempt": { "type": "integer", "minimum": 1 }, + "environment": { "$ref": "#/$defs/producer_environment" }, + "conformance_record": { "$ref": "#/$defs/json_artifact" }, + "source": { "$ref": "#/$defs/source_manifest" }, + "intended_scenario_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/$defs/scenario_id" } + } + } + } + } +} diff --git a/consumer-reference/schema/portable-tokens.schema.json b/consumer-reference/schema/portable-tokens.schema.json new file mode 100644 index 0000000..f74551a --- /dev/null +++ b/consumer-reference/schema/portable-tokens.schema.json @@ -0,0 +1,104 @@ +{ + "$id": "https://stylegallery.example/consumer-reference/schema/portable-tokens.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "alias": { + "pattern": "^\\{[a-z0-9]+(?:[.-][a-z0-9]+)*\\}$", + "type": "string" + }, + "border": { + "additionalProperties": false, + "properties": { + "color": { + "oneOf": [ + { "$ref": "#/$defs/alias" }, + { "$ref": "#/$defs/color" } + ] + }, + "style": { "const": "solid" }, + "width": { + "oneOf": [ + { "$ref": "#/$defs/alias" }, + { "$ref": "#/$defs/dimension" } + ] + } + }, + "required": ["color", "style", "width"], + "type": "object" + }, + "color": { + "additionalProperties": false, + "properties": { + "alpha": { "maximum": 1, "minimum": 0, "type": "number" }, + "colorSpace": { "const": "srgb" }, + "components": { + "items": { "maximum": 1, "minimum": 0, "type": "number" }, + "maxItems": 3, + "minItems": 3, + "type": "array" + } + }, + "required": ["colorSpace", "components"], + "type": "object" + }, + "dimension": { + "additionalProperties": false, + "properties": { + "unit": { "enum": ["px", "rem"] }, + "value": { "type": "number" } + }, + "required": ["unit", "value"], + "type": "object" + }, + "duration": { + "additionalProperties": false, + "properties": { + "unit": { "enum": ["ms", "s"] }, + "value": { "minimum": 0, "type": "number" } + }, + "required": ["unit", "value"], + "type": "object" + }, + "group": { + "additionalProperties": false, + "patternProperties": { + "^(?!\\$)(?!(?:__proto__|prototype|constructor)$)[a-z0-9]+(?:-[a-z0-9]+)*$": { "$ref": "#/$defs/node" } + }, + "properties": { + "$description": { "type": "string" }, + "$type": { "enum": ["border", "color", "dimension", "duration"] } + }, + "type": "object" + }, + "node": { + "oneOf": [ + { "$ref": "#/$defs/token" }, + { "$ref": "#/$defs/group" } + ] + }, + "token": { + "additionalProperties": false, + "properties": { + "$description": { "type": "string" }, + "$type": { "enum": ["border", "color", "dimension", "duration"] }, + "$value": { + "oneOf": [ + { "$ref": "#/$defs/alias" }, + { "$ref": "#/$defs/border" }, + { "$ref": "#/$defs/color" }, + { "$ref": "#/$defs/dimension" }, + { "$ref": "#/$defs/duration" } + ] + } + }, + "required": ["$value"], + "type": "object" + } + }, + "additionalProperties": false, + "patternProperties": { + "^(?!\\$)(?!(?:__proto__|prototype|constructor)$)[a-z0-9]+(?:-[a-z0-9]+)*$": { "$ref": "#/$defs/node" } + }, + "title": "StyleGallery portable design tokens", + "type": "object" +} diff --git a/consumer-reference/schema/promotion-rfc.schema.json b/consumer-reference/schema/promotion-rfc.schema.json new file mode 100644 index 0000000..796940b --- /dev/null +++ b/consumer-reference/schema/promotion-rfc.schema.json @@ -0,0 +1,219 @@ +{ + "$defs": { + "attestation": { + "additionalProperties": false, + "properties": { + "attested_by": { "minLength": 1, "type": "string" }, + "context_id": { "$ref": "#/$defs/slug" }, + "consumer_id": { "$ref": "#/$defs/slug" }, + "count_toward_gate": { "type": "boolean" }, + "organization_id": { "$ref": "#/$defs/slug" }, + "profile_record": { + "pattern": "^(?!/)(?!.*//)(?!.*(?:^|/)\\.{1,2}(?:/|$))(?![A-Za-z][A-Za-z0-9+.-]*:)[^\\\\?#]+\\.json$", + "type": "string" + }, + "relationship": { "enum": ["independent", "related"] } + }, + "required": ["attested_by", "context_id", "consumer_id", "count_toward_gate", "organization_id", "relationship"], + "type": "object" + }, + "evidence": { + "additionalProperties": false, + "properties": { + "id": { "$ref": "#/$defs/slug" }, + "kind": { "enum": ["blocking", "claim_scoped", "regression"] }, + "reference": { "minLength": 1, "type": "string" }, + "status": { "enum": ["failed", "passed"] } + }, + "required": ["id", "kind", "reference", "status"], + "type": "object" + }, + "slug": { + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + } + }, + "$id": "https://stylegallery.example/consumer-reference/schema/promotion-rfc.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { "transition": { "properties": { "to": { "const": "stable" } }, "required": ["to"], "type": "object" } }, + "required": ["transition"] + }, + "then": { + "properties": { + "provenance": { "properties": { "debt_resolved": { "const": true } }, "required": ["debt_resolved"], "type": "object" }, + "stable_scope_decision": { "const": true }, + "support": { "properties": { "status": { "const": "active" } }, "required": ["status"], "type": "object" }, + "evidence": { + "allOf": [ + { "contains": { "properties": { "kind": { "const": "claim_scoped" }, "status": { "const": "passed" } }, "required": ["kind", "status"], "type": "object" } }, + { "contains": { "properties": { "kind": { "const": "regression" }, "status": { "const": "passed" } }, "required": ["kind", "status"], "type": "object" } } + ], + "type": "array" + } + } + } + }, + { + "if": { "properties": { "normative_bypass": { "const": "normative_correctness" } }, "required": ["normative_bypass"] }, + "then": { "properties": { "scope": { "properties": { "kind": { "const": "normative_correctness" } }, "required": ["kind"], "type": "object" } } } + }, + { + "if": { "properties": { "compatibility": { "properties": { "breaking": { "const": true } }, "required": ["breaking"], "type": "object" } }, "required": ["compatibility"] }, + "then": { "properties": { "migration": { "properties": { "required": { "const": true } }, "required": ["required"], "type": "object" } } } + } + ], + "properties": { + "attestations": { + "items": { "$ref": "#/$defs/attestation" }, + "type": "array" + }, + "compatibility": { + "additionalProperties": false, + "properties": { + "breaking": { "type": "boolean" }, + "surface": { "minLength": 1, "type": "string" } + }, + "required": ["breaking", "surface"], + "type": "object" + }, + "decision": { "enum": ["deferred", "rejected"] }, + "deprecation": { + "additionalProperties": false, + "properties": { + "plan": { "minLength": 1, "type": "string" }, + "required": { "type": "boolean" } + }, + "required": ["plan", "required"], + "type": "object" + }, + "eligibility_basis": { "const": "full_contract" }, + "evidence": { + "items": { "$ref": "#/$defs/evidence" }, + "minItems": 1, + "type": "array" + }, + "example_only": { "const": true }, + "exceptions": { + "items": { "minLength": 1, "type": "string" }, + "type": "array" + }, + "id": { "$ref": "#/$defs/slug" }, + "known_issues": { + "items": { "minLength": 1, "type": "string" }, + "type": "array" + }, + "migration": { + "additionalProperties": false, + "properties": { + "plan": { "minLength": 1, "type": "string" }, + "required": { "type": "boolean" } + }, + "required": ["plan", "required"], + "type": "object" + }, + "normative_bypass": { "enum": ["none", "normative_correctness"] }, + "owner": { + "additionalProperties": false, + "properties": { + "enforcement": { "const": "placeholder" }, + "migration": { "minLength": 1, "type": "string" }, + "rollback": { "minLength": 1, "type": "string" }, + "scope": { "minLength": 1, "type": "string" }, + "support": { "minLength": 1, "type": "string" } + }, + "required": ["enforcement", "migration", "rollback", "scope", "support"], + "type": "object" + }, + "provenance": { + "additionalProperties": false, + "properties": { + "debt_resolved": { "type": "boolean" }, + "source": { "minLength": 1, "type": "string" } + }, + "required": ["debt_resolved", "source"], + "type": "object" + }, + "review_independence": { "const": "single_account" }, + "rollback": { + "additionalProperties": false, + "properties": { + "action": { "minLength": 1, "type": "string" }, + "triggers": { + "items": { "minLength": 1, "type": "string" }, + "minItems": 1, + "type": "array" + } + }, + "required": ["action", "triggers"], + "type": "object" + }, + "schema_version": { "const": "1.0" }, + "scope": { + "additionalProperties": false, + "properties": { + "aesthetic": { "const": false }, + "kind": { "enum": ["invariant", "normative_correctness"] }, + "summary": { "minLength": 1, "type": "string" } + }, + "required": ["aesthetic", "kind", "summary"], + "type": "object" + }, + "stable_scope_decision": { "type": "boolean" }, + "support": { + "additionalProperties": false, + "properties": { + "capacity": { "const": "committed" }, + "status": { "const": "active" } + }, + "required": ["capacity", "status"], + "type": "object" + }, + "transition": { + "additionalProperties": false, + "oneOf": [ + { + "properties": { "from": { "const": "consumer_local" }, "to": { "const": "shared_experimental" } }, + "required": ["from", "to"] + }, + { + "properties": { "from": { "const": "shared_experimental" }, "to": { "const": "stable" } }, + "required": ["from", "to"] + } + ], + "properties": { + "from": { "enum": ["consumer_local", "shared_experimental", "stable"] }, + "to": { "enum": ["shared_experimental", "stable"] } + }, + "required": ["from", "to"], + "type": "object" + } + }, + "required": [ + "attestations", + "compatibility", + "decision", + "deprecation", + "eligibility_basis", + "evidence", + "example_only", + "exceptions", + "id", + "known_issues", + "migration", + "normative_bypass", + "owner", + "provenance", + "review_independence", + "rollback", + "schema_version", + "scope", + "stable_scope_decision", + "support", + "transition" + ], + "type": "object" +} diff --git a/consumer-reference/schema/runtime-evidence-manifest.schema.json b/consumer-reference/schema/runtime-evidence-manifest.schema.json new file mode 100644 index 0000000..275c081 --- /dev/null +++ b/consumer-reference/schema/runtime-evidence-manifest.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stylegallery.local/consumer-reference/schema/runtime-evidence-manifest.schema.json", + "title": "Closed component-state runtime evidence manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "record_kind", "claim_boundary", "recorded_at", "environment", "run", "session", "records"], + "properties": { + "schema_version": { "const": "1.0" }, + "record_kind": { "const": "component_state_runtime_manifest" }, + "claim_boundary": { "const": "Scenario artifacts are evidence, not visual regression baselines or accessibility certification." }, + "recorded_at": { "type": "string", "format": "date-time" }, + "environment": { "$ref": "https://stylegallery.local/consumer-reference/schema/capture-session.schema.json#/$defs/environment" }, + "run": { + "type": "object", + "additionalProperties": false, + "required": ["id", "source", "repository", "revision", "attempt"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "source": { "enum": ["local", "github_actions"] }, + "repository": { "type": "string", "minLength": 1 }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "attempt": { "type": "integer", "minimum": 1 } + } + }, + "session": { "$ref": "https://stylegallery.local/consumer-reference/schema/capture-session.schema.json#/$defs/completed_session" }, + "records": { + "type": "array", + "minItems": 1, + "items": { "$ref": "https://stylegallery.local/consumer-reference/schema/evidence-record.schema.json" } + } + } +} diff --git a/consumer-reference/schema/visual-evidence.schema.json b/consumer-reference/schema/visual-evidence.schema.json new file mode 100644 index 0000000..e4d08fd --- /dev/null +++ b/consumer-reference/schema/visual-evidence.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://stylegallery.local/consumer-reference/schema/visual-evidence.schema.json", + "title": "Browser-authored visual capture metadata", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "channel", "capture_session", "captured_at", "profile_id", "scenario_id", "semantic_mode", "source_sha256", "image"], + "properties": { + "schema_version": { "const": "1.0" }, + "channel": { "const": "visual" }, + "capture_session": { "$ref": "https://stylegallery.local/consumer-reference/schema/capture-session.schema.json#/$defs/session_link" }, + "captured_at": { "type": "string", "format": "date-time" }, + "profile_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "scenario_id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "semantic_mode": { "enum": ["action", "toggle", "disclosure", "switch"] }, + "source_sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "image": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "byte_length", "media_type", "width", "height"], + "properties": { + "path": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*\\.png$" }, + "sha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "byte_length": { "type": "integer", "minimum": 1 }, + "media_type": { "const": "image/png" }, + "width": { "type": "integer", "minimum": 44 }, + "height": { "type": "integer", "minimum": 44 } + } + } + } +} diff --git a/design-engineering/consumer-migration-readiness.md b/design-engineering/consumer-migration-readiness.md new file mode 100644 index 0000000..cff2143 --- /dev/null +++ b/design-engineering/consumer-migration-readiness.md @@ -0,0 +1,124 @@ +--- +type: Domain Guide +title: Consumer Migration Readiness +description: Evidence-bound method for deciding whether a consumer migration preserves its declared behavior. +domain: design-engineering +lifecycle: experimental +provenance_kind: local +--- + +# Consumer Migration Readiness + +Use this method only when an existing consumer is being migrated and its behavior must remain demonstrably equivalent. It turns migration intent into a consumer-owned conformance record and executable page evidence without making StyleGallery the owner of product code or visual values. + +## Repository Boundary + +The consuming repository owns its conformance record, source paths, runtime scenarios, evidence artifacts, adoption mappings, deviations, debt, and approval decision. StyleGallery supplies an experimental method, closed schemas, and validators. It does not certify the consumer, define product defaults, or turn a local adoption into a shared standard. + +Ordinary implementation handoffs remain unchanged and may use `Consumer reference: not_applicable` with a sentence reason. Only a migration handoff that elects this method adds `Consumer migration conformance: declared` and one normalized repository-relative JSON record path. + +## Reusable Method + +1. Inventory the old and new consumer behavior before changing implementation. +2. Create one consumer-local record using the [consumer conformance schema](../consumer-reference/schema/consumer-conformance-record.schema.json). +3. Explicitly classify every migration dimension as `applicable` with runtime scenario IDs or `not_applicable` with a sentence reason. +4. Map each adopted StyleGallery source anchor to a concrete local component, selector, or module plus its local decision, deviations, debt, and scenario IDs. +5. Execute the named unit, integration, or browser command. A prose checklist, screenshot, or generated fixture alone is not runtime proof. +6. For page work, create one source-bound evidence session, execute the browser matrix, finalize the manifest, and validate the completed session against the conformance record. +7. Review the [consumer migration evidence gate](../quality/gates/consumer-migration-evidence.md). Block migration when required evidence is missing, stale, cross-session, source-drifted, or failed. + +## Migration Dimension Contract + +The record must classify exactly these thirteen dimensions: + +| Dimension | What the consumer must preserve or explicitly exclude | +| --- | --- | +| `behavior_inventory` | The complete named behavior surface being migrated. | +| `route_parity` | Routes, entry points, and navigation outcomes. | +| `field_parity` | Accepted fields, output fields, validation, and serialization. | +| `action_parity` | User and programmatic actions plus observable outcomes. | +| `state_transitions` | Allowed state changes, guards, and terminal states. | +| `contract_precedence` | Which rule wins when old, new, and local contracts conflict. | +| `direct_mutation` | Effects of direct state or model mutation. | +| `indirect_mutation` | Effects reached through events, adapters, or dependent modules. | +| `persistence_round_trip` | Save, reload, serialization, and restoration behavior. | +| `reset_boundary` | What reset clears, preserves, and returns to defaults. | +| `exact_time_boundary` | Inclusive or exclusive timing behavior at exact boundaries. | +| `defaults_tri_state_mapping` | Mapping among absent, explicit default, and explicit non-default values. | +| `atomic_batch_behavior` | All-or-nothing behavior, ordering, and rollback for grouped changes. | + +An `applicable` dimension references at least one declared runtime scenario. A `not_applicable` dimension includes a concrete sentence reason. Silence is never a classification. + +## Opinionated Guidance + +- Prefer the smallest scenario that proves an observable outcome over broad internal coverage. +- Pin both the consumer revision and each adopted StyleGallery revision; a branch name or moving tag is insufficient. +- Record local deviations honestly. Conformance means the declared boundary is internally consistent, not that every StyleGallery suggestion was copied. +- Treat expired debt as a decision trigger. Do not rewrite evidence timestamps to make a stale review appear current. +- Keep page evidence tied to the exact source inventory and browser run that produced it. + +## Platform-Specific Guidance + +Browser page evidence uses the required viewport set `320`, `375`, `768`, `1024`, and `1440` pixels plus the declared container, content, state, overlay, and page-scale cases. Platform-specific raster identity may supplement computed assertions, but it cannot replace semantic, focus, overflow, or contrast checks and cannot imply cross-platform pixel equivalence. + +## Unsupported Absolutes + +Passing this method does not prove complete accessibility, product correctness, usability, visual quality, independent adoption, cross-browser equivalence, or owner approval. Synthetic fixtures prove validator behavior only. A pinned revision proves source identity, not source authority or consumer suitability. + +## Verification Contract + +The minimum verification chain is: + +```txt +consumer source inventory + -> conformance record with 13 explicit classifications + -> executable scenarios with zero exit and result artifacts + -> adoption mappings with pinned StyleGallery anchors + -> optional source-bound page-evidence manifest + -> consumer migration evidence gate decision +``` + +Validate a record without applicable page evidence directly: + +```sh +node /scripts/validate-consumer-conformance.mjs \ + --root \ + --record \ + --json +``` + +Unit and integration scenarios declare a shell-free Node `argv` array beginning with `node`. The validator checks out the exact consumer revision in an isolated worktree, enables Node's permission model without child-process, worker, native-addon, or WASI grants, supplies only `PATH`, `CI=1`, deterministic locale flags, `STYLEGALLERY_RESULT_ARTIFACT`, and `STYLEGALLERY_RUNTIME_CONTEXT`, and requires the command to create that result artifact during the run. Commands may read and write only inside the isolated checkout, must be self-contained at the pinned revision, and cannot override the governed permission flags. The validator does not reuse the caller's `node_modules` or forward caller secrets. + +When `page_evidence.status` is `applicable`, use one artifact root for the complete lifecycle. Start the source-bound session before the browser run, write each runner result beneath that artifact root, finalize the session, and then validate the record against the completed artifacts: + +```sh +node /scripts/create-page-evidence-session.mjs \ + --root \ + --record \ + --artifact-root \ + --json + +node /scripts/finalize-page-evidence.mjs \ + --root \ + --artifact-root \ + --runner-result \ + --review-by \ + --json + +node /scripts/validate-consumer-conformance.mjs \ + --root \ + --record \ + --artifact-root \ + --json +``` + +`` resolves from ``, and each `` resolves from ``. The completed manifest must match the consumer repository, revision, relevant source set, browser scenario set, run/session identities, and source digests. The StyleGallery checkout used for validation must contain every pinned StyleGallery revision; a shallow checkout that omits a historical pin fails closed. See [Consumer Migration Evidence](../quality/evidence/consumer-migration.md) for what each channel can and cannot support. + +## Source, License, And Attribution + +This is a StyleGallery-local method. It has no external source snapshot and intentionally declares `provenance_kind: local` rather than upstream repository metadata. + +## IA Navigation + +Parent: [Design Engineering](index.md). +Next: [Consumer Migration Evidence Gate](../quality/gates/consumer-migration-evidence.md). diff --git a/design-engineering/index.md b/design-engineering/index.md index 018b719..95823d5 100644 --- a/design-engineering/index.md +++ b/design-engineering/index.md @@ -11,6 +11,8 @@ Out of scope: a second universal principle set, unsupported claims of beauty or ## Documents - [Interface Craft Decisions](interface-craft.md) connects product purpose, implementation detail, and verification without treating taste as proof. +- [Consumer Migration Readiness](consumer-migration-readiness.md) turns migration intent into a consumer-owned thirteen-dimension conformance record and executable evidence chain. +- [Reference Profiles](reference-profiles/index.md) provides two explicitly selected, related adversarial examples and a documentation-only external adaptation route. ## Relationship To Shared Quality @@ -23,4 +25,4 @@ See [StyleGallery Domains](../DOMAINS.md) for lifecycle, provenance, page member ## IA Navigation Parent: [StyleGallery](../index.md). -Next: [Interface Craft Decisions](interface-craft.md). +Next: [Consumer Migration Readiness](consumer-migration-readiness.md). diff --git a/design-engineering/reference-profiles/external-adaptation/index.md b/design-engineering/reference-profiles/external-adaptation/index.md new file mode 100644 index 0000000..8ab711a --- /dev/null +++ b/design-engineering/reference-profiles/external-adaptation/index.md @@ -0,0 +1,12 @@ +# External Consumer Adaptation + +Synthetic validator coverage only; no durable adopter record exists in this route. + +An external consumer keeps its canonical JSON record in its own repository, selects it explicitly there, and points only toward the pinned Layout source. StyleGallery documents that route without storing an adopter, attestation, production, independence, or promotion claim. + +The validator exercises a synthetic local-as-external mutation and rejects any JSON record placed below this documentation directory. + +## IA Navigation + +Parent: [Reference Profiles](../index.md). +Next: [Platform Guides](../../../platform-guides/index.md). diff --git a/design-engineering/reference-profiles/governed-local/editorial/components/button.component.json b/design-engineering/reference-profiles/governed-local/editorial/components/button.component.json new file mode 100644 index 0000000..fa3a4e6 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/editorial/components/button.component.json @@ -0,0 +1,125 @@ +{ + "schema_version": "1.0", + "record_kind": "component", + "component_id": "button", + "profile_id": "editorial-reference-profile", + "anatomy": [ + "control", + "label", + "status" + ], + "semantic_modes": [ + { + "id": "action", + "element": "button", + "role": "button", + "keys": [ + "Enter", + "Space" + ], + "aria_states": [ + { + "name": "pressed", + "status": "prohibited", + "reason": "Action buttons do not expose toggle state." + }, + { + "name": "expanded", + "status": "not_applicable", + "reason": "Action buttons do not control disclosed content." + }, + { + "name": "busy", + "status": "conditional", + "condition": "An invoked action is still processing.", + "resolution": "required" + }, + { + "name": "disabled", + "status": "conditional", + "condition": "The action is unavailable to the current user.", + "resolution": "required" + } + ], + "activation": "invoke" + }, + { + "id": "toggle", + "element": "button", + "role": "button", + "keys": [ + "Enter", + "Space" + ], + "aria_states": [ + { + "name": "pressed", + "status": "required", + "values": [ + "false", + "true" + ] + }, + { + "name": "expanded", + "status": "prohibited", + "reason": "Toggle buttons expose pressed rather than disclosure state." + }, + { + "name": "busy", + "status": "not_applicable", + "reason": "The governed toggle fixture has no asynchronous operation." + }, + { + "name": "disabled", + "status": "not_applicable", + "reason": "The governed toggle fixture remains available for activation." + } + ], + "activation": "toggle" + }, + { + "id": "disclosure", + "element": "button", + "role": "button", + "keys": [ + "Enter", + "Space" + ], + "aria_states": [ + { + "name": "expanded", + "status": "required", + "values": [ + "false", + "true" + ] + }, + { + "name": "pressed", + "status": "prohibited", + "reason": "Disclosure buttons expose expanded rather than toggle state." + }, + { + "name": "busy", + "status": "conditional", + "condition": "Expanded controlled content is still loading.", + "resolution": "required" + }, + { + "name": "disabled", + "status": "not_applicable", + "reason": "The governed disclosure fixture remains available for activation." + } + ], + "activation": "control" + } + ], + "versions": { + "design_revision": "2026.07.14", + "package_version": "0.1.0-preview.1", + "api_version": "1.0", + "delivery_channel": "preview", + "lifecycle": "experimental" + } +} diff --git a/design-engineering/reference-profiles/governed-local/editorial/evidence/button.evidence.json b/design-engineering/reference-profiles/governed-local/editorial/evidence/button.evidence.json new file mode 100644 index 0000000..d631fdb --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/editorial/evidence/button.evidence.json @@ -0,0 +1,3504 @@ +{ + "claim_boundary": "Scenario artifacts are evidence, not visual regression baselines or accessibility certification.", + "component_id": "button", + "passes": [ + { + "artifact": { + "path": "runtime/editorial-action-disabled-busy.png", + "byte_length": 3009, + "media_type": "image/png", + "sha256": "sha256:4b3df5ceab968c2808b21319b8b7694a723db61dd11cf6c3173664604f4e3776", + "height": 56, + "width": 640 + }, + "channel": "visual", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-disabled-busy-visual", + "recorded_at": "2026-07-14T06:33:48.519Z", + "result": { + "observed": "Recorded visual evidence for action-disabled-busy; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-disabled-busy", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "busy", + "disabled" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-action-disabled-busy.dom.json", + "byte_length": 8290, + "media_type": "application/json", + "sha256": "sha256:ab25974eaf732f35b45f1f8f6640ae43aabaf82f4a1caca5eecedec90b95347c" + }, + "channel": "dom", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-disabled-busy-dom", + "recorded_at": "2026-07-14T06:33:48.519Z", + "result": { + "observed": "Recorded dom evidence for action-disabled-busy; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-disabled-busy", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "busy", + "disabled" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-action-disabled-busy.ax.json", + "byte_length": 8209, + "media_type": "application/json", + "sha256": "sha256:d0869084b405cde4f0343f07d88b66c1331e78dcc7e0d83093d32040896cd736" + }, + "channel": "ax", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-disabled-busy-ax", + "recorded_at": "2026-07-14T06:33:48.519Z", + "result": { + "observed": "Recorded ax evidence for action-disabled-busy; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-disabled-busy", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "busy", + "disabled" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-action-focused.png", + "byte_length": 1695, + "media_type": "image/png", + "sha256": "sha256:ad888e0f6eb4f57cb020672af3965e33245330af2c6547247d60bb9a4080afeb", + "height": 56, + "width": 640 + }, + "channel": "visual", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-focused-visual", + "recorded_at": "2026-07-14T06:33:48.646Z", + "result": { + "observed": "Recorded visual evidence for action-focused; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-focused", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "focus" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-action-focused.dom.json", + "byte_length": 8230, + "media_type": "application/json", + "sha256": "sha256:0bda7500dae5d94f823831548374c2d7444eaf8bd687fcaa592caeeaf763cd2b" + }, + "channel": "dom", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-focused-dom", + "recorded_at": "2026-07-14T06:33:48.646Z", + "result": { + "observed": "Recorded dom evidence for action-focused; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-focused", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "focus" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-action-focused.ax.json", + "byte_length": 8150, + "media_type": "application/json", + "sha256": "sha256:64a6b20340c3812d06fcdff4f132df15227a3a893e4939eb61381ca12824defe" + }, + "channel": "ax", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-focused-ax", + "recorded_at": "2026-07-14T06:33:48.646Z", + "result": { + "observed": "Recorded ax evidence for action-focused; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-focused", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "focus" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-action-loading-busy.png", + "byte_length": 3019, + "media_type": "image/png", + "sha256": "sha256:90379d9605eb1f544f3f19192a2299b4369487aff3e42bd904be2c498adcf18d", + "height": 56, + "width": 640 + }, + "channel": "visual", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-loading-busy-visual", + "recorded_at": "2026-07-14T06:33:48.756Z", + "result": { + "observed": "Recorded visual evidence for action-loading-busy; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-loading-busy", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "busy", + "focus", + "loading" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-action-loading-busy.dom.json", + "byte_length": 8276, + "media_type": "application/json", + "sha256": "sha256:d61c0b69ff4751ae958e4f7bab971d8767cd5dab9e832b601cc0828da9f6aa72" + }, + "channel": "dom", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-loading-busy-dom", + "recorded_at": "2026-07-14T06:33:48.756Z", + "result": { + "observed": "Recorded dom evidence for action-loading-busy; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-loading-busy", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "busy", + "focus", + "loading" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-action-loading-busy.ax.json", + "byte_length": 8176, + "media_type": "application/json", + "sha256": "sha256:146911dc4f509ac7333465f252f4205c2a7eb0de4d0a4dee70b30fe71e49cd80" + }, + "channel": "ax", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-loading-busy-ax", + "recorded_at": "2026-07-14T06:33:48.756Z", + "result": { + "observed": "Recorded ax evidence for action-loading-busy; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-loading-busy", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "busy", + "focus", + "loading" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-disclosure-expanded-loading.png", + "byte_length": 7306, + "media_type": "image/png", + "sha256": "sha256:6b94bf18d52b28a0ac616d6180fd004c5f9dbb5d72bae944900e2b1978d7ac9f", + "height": 89, + "width": 640 + }, + "channel": "visual", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "disclosure-expanded-loading-visual", + "recorded_at": "2026-07-14T06:33:48.879Z", + "result": { + "observed": "Recorded visual evidence for disclosure-expanded-loading; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "disclosure-expanded-loading", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "disclosure", + "state_set": [ + "busy", + "expanded", + "focus", + "loading" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-disclosure-expanded-loading.dom.json", + "byte_length": 8333, + "media_type": "application/json", + "sha256": "sha256:649083cf7bd92df1baac83cd6e050a1e0e9ea8ea300a2edaa7ffb8dfb09f3cec" + }, + "channel": "dom", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "disclosure-expanded-loading-dom", + "recorded_at": "2026-07-14T06:33:48.879Z", + "result": { + "observed": "Recorded dom evidence for disclosure-expanded-loading; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "disclosure-expanded-loading", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "disclosure", + "state_set": [ + "busy", + "expanded", + "focus", + "loading" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-disclosure-expanded-loading.ax.json", + "byte_length": 8214, + "media_type": "application/json", + "sha256": "sha256:a335914fea6d7933d4a68f84951a53e2679be43faeda67ea3f26338c27fa1ba2" + }, + "channel": "ax", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "disclosure-expanded-loading-ax", + "recorded_at": "2026-07-14T06:33:48.879Z", + "result": { + "observed": "Recorded ax evidence for disclosure-expanded-loading; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "disclosure-expanded-loading", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "disclosure", + "state_set": [ + "busy", + "expanded", + "focus", + "loading" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-toggle-focused-pressed.png", + "byte_length": 2370, + "media_type": "image/png", + "sha256": "sha256:51404ac48079f5925ec2e674852f7fb38fb4c9fddfda0e74e3618a843edc21a3", + "height": 56, + "width": 640 + }, + "channel": "visual", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "toggle-focused-pressed-visual", + "recorded_at": "2026-07-14T06:33:49.004Z", + "result": { + "observed": "Recorded visual evidence for toggle-focused-pressed; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "toggle-focused-pressed", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "toggle", + "state_set": [ + "focus", + "pressed" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-toggle-focused-pressed.dom.json", + "byte_length": 8270, + "media_type": "application/json", + "sha256": "sha256:bbc4bc195fc3a003c8c5126637e102e7e573a8f3568cc7a725c49d506802c2e6" + }, + "channel": "dom", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "toggle-focused-pressed-dom", + "recorded_at": "2026-07-14T06:33:49.004Z", + "result": { + "observed": "Recorded dom evidence for toggle-focused-pressed; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "toggle-focused-pressed", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "toggle", + "state_set": [ + "focus", + "pressed" + ] + } + }, + { + "artifact": { + "path": "runtime/editorial-toggle-focused-pressed.ax.json", + "byte_length": 8184, + "media_type": "application/json", + "sha256": "sha256:e0709f66f261a595827a43941a9b51a65c1789540c193c17a7dc0b92884ccdc1" + }, + "channel": "ax", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "toggle-focused-pressed-ax", + "recorded_at": "2026-07-14T06:33:49.004Z", + "result": { + "observed": "Recorded ax evidence for toggle-focused-pressed; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "toggle-focused-pressed", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "toggle", + "state_set": [ + "focus", + "pressed" + ] + } + } + ], + "profile_id": "editorial-reference-profile", + "schema_version": "1.0" +} diff --git a/design-engineering/reference-profiles/governed-local/editorial/fixtures/button.fixture.json b/design-engineering/reference-profiles/governed-local/editorial/fixtures/button.fixture.json new file mode 100644 index 0000000..213072d --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/editorial/fixtures/button.fixture.json @@ -0,0 +1,65 @@ +{ + "schema_version": "1.0", + "fixture_id": "editorial-button-runtime", + "component_id": "button", + "profile_id": "editorial-reference-profile", + "component_record": "../components/button.component.json", + "state_record": "../states/button.states.json", + "scenarios": [ + { + "id": "action-disabled-busy", + "semantic_mode": "action", + "label": "Publish unavailable", + "activation_key": "Enter", + "required_channels": [ + "visual", + "dom", + "ax" + ] + }, + { + "id": "action-focused", + "semantic_mode": "action", + "label": "Publish", + "activation_key": "Enter", + "required_channels": [ + "visual", + "dom", + "ax" + ] + }, + { + "id": "action-loading-busy", + "semantic_mode": "action", + "label": "Publishing", + "activation_key": "Enter", + "required_channels": [ + "visual", + "dom", + "ax" + ] + }, + { + "id": "disclosure-expanded-loading", + "semantic_mode": "disclosure", + "label": "Show footnotes", + "activation_key": "Enter", + "required_channels": [ + "visual", + "dom", + "ax" + ] + }, + { + "id": "toggle-focused-pressed", + "semantic_mode": "toggle", + "label": "Save article", + "activation_key": "Space", + "required_channels": [ + "visual", + "dom", + "ax" + ] + } + ] +} diff --git a/design-engineering/reference-profiles/governed-local/editorial/generated/evidence-coverage.md b/design-engineering/reference-profiles/governed-local/editorial/generated/evidence-coverage.md new file mode 100644 index 0000000..8f1eabe --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/editorial/generated/evidence-coverage.md @@ -0,0 +1,23 @@ +--- +type: Generated Evidence +title: "editorial button evidence coverage" +description: "Claim-scoped evidence channels and artifacts derived from canonical JSON." +--- + + +# editorial button evidence coverage + +Claim boundary: Scenario artifacts are evidence, not visual regression baselines or accessibility certification. + +| Scenario | Required channels | Recorded channels | Pass records | Artifacts | +| --- | --- | --- | --- | --- | +| action-disabled-busy | visual, dom, ax | ax, dom, visual | 3 | runtime/editorial-action-disabled-busy.ax.json
runtime/editorial-action-disabled-busy.dom.json
runtime/editorial-action-disabled-busy.png | +| action-focused | visual, dom, ax | ax, dom, visual | 3 | runtime/editorial-action-focused.ax.json
runtime/editorial-action-focused.dom.json
runtime/editorial-action-focused.png | +| action-loading-busy | visual, dom, ax | ax, dom, visual | 3 | runtime/editorial-action-loading-busy.ax.json
runtime/editorial-action-loading-busy.dom.json
runtime/editorial-action-loading-busy.png | +| disclosure-expanded-loading | visual, dom, ax | ax, dom, visual | 3 | runtime/editorial-disclosure-expanded-loading.ax.json
runtime/editorial-disclosure-expanded-loading.dom.json
runtime/editorial-disclosure-expanded-loading.png | +| toggle-focused-pressed | visual, dom, ax | ax, dom, visual | 3 | runtime/editorial-toggle-focused-pressed.ax.json
runtime/editorial-toggle-focused-pressed.dom.json
runtime/editorial-toggle-focused-pressed.png | + +## IA Navigation + +Parent: [Keyboard Matrix](keyboard-matrix.md). +Next: [Reference Profiles](../../../index.md). diff --git a/design-engineering/reference-profiles/governed-local/editorial/generated/keyboard-matrix.md b/design-engineering/reference-profiles/governed-local/editorial/generated/keyboard-matrix.md new file mode 100644 index 0000000..f24ea2f --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/editorial/generated/keyboard-matrix.md @@ -0,0 +1,19 @@ +--- +type: Generated Evidence +title: "editorial button keyboard matrix" +description: "Keyboard behavior and live fixture coverage derived from canonical JSON." +--- + + +# editorial button keyboard matrix + +| Mode | Element | Role | Keys | Behavior | Runtime scenario | +| --- | --- | --- | --- | --- | --- | +| action | button | button | Enter, Space | invoke | action-loading-busy | +| disclosure | button | button | Enter, Space | control | disclosure-expanded-loading | +| toggle | button | button | Enter, Space | toggle | toggle-focused-pressed | + +## IA Navigation + +Parent: [State Matrix](state-matrix.md). +Next: [Evidence Coverage](evidence-coverage.md). diff --git a/design-engineering/reference-profiles/governed-local/editorial/generated/state-matrix.md b/design-engineering/reference-profiles/governed-local/editorial/generated/state-matrix.md new file mode 100644 index 0000000..61f5ebb --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/editorial/generated/state-matrix.md @@ -0,0 +1,21 @@ +--- +type: Generated Evidence +title: "editorial button state matrix" +description: "Compound state sets and channel expectations derived from canonical JSON." +--- + + +# editorial button state matrix + +| Scenario | Mode | State set | ARIA | Visual | Activation | +| --- | --- | --- | --- | --- | --- | +| action-disabled-busy | action | busy, disabled | aria-busy=true
aria-disabled=true | busy, disabled | suppressed | +| action-focused | action | focus | none | focus, ready | allowed | +| action-loading-busy | action | busy, focus, loading | aria-busy=true | busy, focus, loading | suppressed | +| disclosure-expanded-loading | disclosure | busy, expanded, focus, loading | aria-busy=true
aria-expanded=true | busy, expanded, focus, loading | allowed | +| toggle-focused-pressed | toggle | focus, pressed | aria-pressed=true | focus, pressed | allowed | + +## IA Navigation + +Parent: [Governed Local Profiles](../../index.md). +Next: [Keyboard Matrix](keyboard-matrix.md). diff --git a/design-engineering/reference-profiles/governed-local/editorial/local-foundations.json b/design-engineering/reference-profiles/governed-local/editorial/local-foundations.json new file mode 100644 index 0000000..c5db770 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/editorial/local-foundations.json @@ -0,0 +1,12 @@ +{ + "bindings": { + "accent": "{color.accent}", + "body_font_family": "Georgia, Times New Roman, serif", + "canvas": "{color.paper}", + "content_rhythm": "{space.rhythm}", + "text": "{color.ink}" + }, + "identity": "Warm long-form editorial publication", + "profile_id": "editorial-reference-profile", + "schema_version": "1.0" +} diff --git a/design-engineering/reference-profiles/governed-local/editorial/profile.json b/design-engineering/reference-profiles/governed-local/editorial/profile.json new file mode 100644 index 0000000..0a5a592 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/editorial/profile.json @@ -0,0 +1,43 @@ +{ + "artifact_mode": "governed_local", + "component_records": ["components/button.component.json"], + "default": false, + "environment_assumptions": { + "reset": { + "body_margin": "0", + "box_sizing": "border-box", + "figure_margin": "0" + }, + "user_agent_styles": "Preserve semantic browser defaults except for the declared reset." + }, + "example_only": true, + "evidence_records": ["evidence/button.evidence.json"], + "fixture_independence": "related", + "fixture_records": ["fixtures/button.fixture.json"], + "generated_records": ["generated/evidence-coverage.md", "generated/keyboard-matrix.md", "generated/state-matrix.md"], + "handoff": { + "record": "design-engineering/reference-profiles/governed-local/editorial/profile.json", + "status": "declared" + }, + "id": "editorial-reference-profile", + "layout_source_sha": "775430bbaf4ee208a642220f440f6926d79c90a3", + "local_foundations": "local-foundations.json", + "maturity": "experimental", + "owner": { + "enforcement": "placeholder", + "name": "Design Engineering owner" + }, + "profile_kind": "governed_local", + "related_fixture_set_id": "layout-identity-adversarial-pair", + "review_independence": "single_account", + "schema_version": "1.0", + "selection": { + "method": "profile_path", + "required": true + }, + "support": { + "status": "active" + }, + "state_records": ["states/button.states.json"], + "tokens": "tokens.dtcg.json" +} diff --git a/design-engineering/reference-profiles/governed-local/editorial/states/button.states.json b/design-engineering/reference-profiles/governed-local/editorial/states/button.states.json new file mode 100644 index 0000000..b3d3c45 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/editorial/states/button.states.json @@ -0,0 +1,195 @@ +{ + "schema_version": "1.0", + "record_kind": "state_set", + "component_id": "button", + "profile_id": "editorial-reference-profile", + "visual_environments": [ + { + "id": "darwin-arm64-chromium-1228", + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + { + "id": "linux-amd64-chromium-1228", + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a", + "platform": "linux/amd64", + "playwright": "1.61.0", + "viewport": "1024x768" + } + ], + "scenarios": [ + { + "id": "action-disabled-busy", + "semantic_mode": "action", + "states": [ + "busy", + "disabled" + ], + "aria": { + "busy": "true", + "disabled": "true" + }, + "expected": { + "visual": [ + "busy", + "disabled" + ], + "visual_image": [ + { "environment_id": "darwin-arm64-chromium-1228", "sha256": "sha256:4b3df5ceab968c2808b21319b8b7694a723db61dd11cf6c3173664604f4e3776", "width": 640, "height": 56 }, + { "environment_id": "linux-amd64-chromium-1228", "sha256": "sha256:24bad38e0be21ce73d9450ce0b47aa156550ec3772e3f7842f00be6f3543f727", "width": 640, "height": 56 } + ], + "dom": { + "active": "false", + "aria-busy": "true", + "disabled": "true", + "role": "button" + }, + "ax": { + "busy": true, + "disabled": true, + "focused": false, + "role": "button" + }, + "activation": "suppressed" + } + }, + { + "id": "action-focused", + "semantic_mode": "action", + "states": [ + "focus" + ], + "aria": {}, + "expected": { + "visual": [ + "focus", + "ready" + ], + "visual_image": [ + { "environment_id": "darwin-arm64-chromium-1228", "sha256": "sha256:ad888e0f6eb4f57cb020672af3965e33245330af2c6547247d60bb9a4080afeb", "width": 640, "height": 56 }, + { "environment_id": "linux-amd64-chromium-1228", "sha256": "sha256:a4a954932d60a1602c2c7f8842e69280c94171abd4103cf66ae48fddc8893c96", "width": 640, "height": 56 } + ], + "dom": { + "active": "true", + "role": "button" + }, + "ax": { + "focused": true, + "role": "button" + }, + "activation": "allowed" + } + }, + { + "id": "action-loading-busy", + "semantic_mode": "action", + "states": [ + "busy", + "focus", + "loading" + ], + "aria": { + "busy": "true" + }, + "expected": { + "visual": [ + "busy", + "focus", + "loading" + ], + "visual_image": [ + { "environment_id": "darwin-arm64-chromium-1228", "sha256": "sha256:90379d9605eb1f544f3f19192a2299b4369487aff3e42bd904be2c498adcf18d", "width": 640, "height": 56 }, + { "environment_id": "linux-amd64-chromium-1228", "sha256": "sha256:44e729b3cf5a2673edbb89d73b36ebe752ca0b465002745949964fa6e00f9d25", "width": 640, "height": 56 } + ], + "dom": { + "active": "true", + "aria-busy": "true", + "role": "button" + }, + "ax": { + "busy": true, + "focused": true, + "role": "button" + }, + "activation": "suppressed" + } + }, + { + "id": "disclosure-expanded-loading", + "semantic_mode": "disclosure", + "states": [ + "busy", + "expanded", + "focus", + "loading" + ], + "aria": { + "busy": "true", + "expanded": "true" + }, + "expected": { + "visual": [ + "busy", + "expanded", + "focus", + "loading" + ], + "visual_image": [ + { "environment_id": "darwin-arm64-chromium-1228", "sha256": "sha256:6b94bf18d52b28a0ac616d6180fd004c5f9dbb5d72bae944900e2b1978d7ac9f", "width": 640, "height": 89 }, + { "environment_id": "linux-amd64-chromium-1228", "sha256": "sha256:a8e581adf5de79ab6436c8f9deb46d8a9165698b6bfb4e9ae37928fddf681ee6", "width": 640, "height": 88 } + ], + "dom": { + "active": "true", + "aria-busy": "true", + "aria-expanded": "true", + "role": "button" + }, + "ax": { + "busy": true, + "expanded": true, + "focused": true, + "role": "button" + }, + "activation": "allowed" + } + }, + { + "id": "toggle-focused-pressed", + "semantic_mode": "toggle", + "states": [ + "focus", + "pressed" + ], + "aria": { + "pressed": "true" + }, + "expected": { + "visual": [ + "focus", + "pressed" + ], + "visual_image": [ + { "environment_id": "darwin-arm64-chromium-1228", "sha256": "sha256:51404ac48079f5925ec2e674852f7fb38fb4c9fddfda0e74e3618a843edc21a3", "width": 640, "height": 56 }, + { "environment_id": "linux-amd64-chromium-1228", "sha256": "sha256:11c6182ae9258f8593aa0a440470b4bc55959e1d723ab2e399bb729b6ed5fdcc", "width": 640, "height": 56 } + ], + "dom": { + "active": "true", + "aria-pressed": "true", + "role": "button" + }, + "ax": { + "focused": true, + "pressed": true, + "role": "button" + }, + "activation": "allowed" + } + } + ] +} diff --git a/design-engineering/reference-profiles/governed-local/editorial/tokens.dtcg.json b/design-engineering/reference-profiles/governed-local/editorial/tokens.dtcg.json new file mode 100644 index 0000000..ba77da2 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/editorial/tokens.dtcg.json @@ -0,0 +1,17 @@ +{ + "color": { + "$type": "color", + "accent": { "$value": { "alpha": 1, "colorSpace": "srgb", "components": [0.58, 0.2, 0.12] } }, + "ink": { "$value": { "alpha": 1, "colorSpace": "srgb", "components": [0.12, 0.09, 0.07] } }, + "paper": { "$value": { "alpha": 1, "colorSpace": "srgb", "components": [0.97, 0.93, 0.84] } } + }, + "duration": { + "$type": "duration", + "feedback": { "$value": { "unit": "ms", "value": 160 } } + }, + "space": { + "$type": "dimension", + "page": { "$value": { "unit": "rem", "value": 1.5 } }, + "rhythm": { "$value": { "unit": "rem", "value": 0.75 } } + } +} diff --git a/design-engineering/reference-profiles/governed-local/index.md b/design-engineering/reference-profiles/governed-local/index.md new file mode 100644 index 0000000..59b2f32 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/index.md @@ -0,0 +1,26 @@ +# Governed Local Reference Profiles + +Editorial and terminal are experimental, example-only profiles that use the same Layout revision and related fixture set. Selection is always an explicit `profile.json` path; neither profile is a default. + +Their token and foundation values intentionally differ. Their shared relationship means they cannot count as independent consumers, adopters, or promotion evidence. + +## Profile Records + +- [Editorial profile](editorial/profile.json) describes a warm publication identity. +- [Terminal profile](terminal/profile.json) describes a dark operator-console identity. + +The sibling `tokens.dtcg.json` files use the restricted portable token subset. The sibling `local-foundations.json` files keep identity bindings local to each profile. + +## Generated Evidence Routes + +The generated matrices expose the declared component states, keyboard expectations, and evidence coverage without making a profile the default. + +| Profile | State matrix | Keyboard matrix | Evidence coverage | +| --- | --- | --- | --- | +| Editorial | [Editorial state matrix](editorial/generated/state-matrix.md) | [Editorial keyboard matrix](editorial/generated/keyboard-matrix.md) | [Editorial evidence coverage](editorial/generated/evidence-coverage.md) | +| Terminal | [Terminal state matrix](terminal/generated/state-matrix.md) | [Terminal keyboard matrix](terminal/generated/keyboard-matrix.md) | [Terminal evidence coverage](terminal/generated/evidence-coverage.md) | + +## IA Navigation + +Parent: [Reference Profiles](../index.md). +Next: [External Adaptation](../external-adaptation/index.md). diff --git a/design-engineering/reference-profiles/governed-local/terminal/components/button.component.json b/design-engineering/reference-profiles/governed-local/terminal/components/button.component.json new file mode 100644 index 0000000..0a62aa4 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/terminal/components/button.component.json @@ -0,0 +1,125 @@ +{ + "schema_version": "1.0", + "record_kind": "component", + "component_id": "button", + "profile_id": "terminal-reference-profile", + "anatomy": [ + "control", + "label", + "status" + ], + "semantic_modes": [ + { + "id": "action", + "element": "button", + "role": "button", + "keys": [ + "Enter", + "Space" + ], + "aria_states": [ + { + "name": "pressed", + "status": "prohibited", + "reason": "Action buttons do not expose toggle state." + }, + { + "name": "expanded", + "status": "not_applicable", + "reason": "Action buttons do not control disclosed content." + }, + { + "name": "busy", + "status": "conditional", + "condition": "An invoked action is still processing.", + "resolution": "required" + }, + { + "name": "disabled", + "status": "conditional", + "condition": "The action is unavailable to the current user.", + "resolution": "required" + } + ], + "activation": "invoke" + }, + { + "id": "toggle", + "element": "button", + "role": "button", + "keys": [ + "Enter", + "Space" + ], + "aria_states": [ + { + "name": "pressed", + "status": "required", + "values": [ + "false", + "true" + ] + }, + { + "name": "expanded", + "status": "prohibited", + "reason": "Toggle buttons expose pressed rather than disclosure state." + }, + { + "name": "busy", + "status": "not_applicable", + "reason": "The governed toggle fixture has no asynchronous operation." + }, + { + "name": "disabled", + "status": "not_applicable", + "reason": "The governed toggle fixture remains available for activation." + } + ], + "activation": "toggle" + }, + { + "id": "disclosure", + "element": "button", + "role": "button", + "keys": [ + "Enter", + "Space" + ], + "aria_states": [ + { + "name": "expanded", + "status": "required", + "values": [ + "false", + "true" + ] + }, + { + "name": "pressed", + "status": "prohibited", + "reason": "Disclosure buttons expose expanded rather than toggle state." + }, + { + "name": "busy", + "status": "conditional", + "condition": "Expanded controlled content is still loading.", + "resolution": "required" + }, + { + "name": "disabled", + "status": "not_applicable", + "reason": "The governed disclosure fixture remains available for activation." + } + ], + "activation": "control" + } + ], + "versions": { + "design_revision": "2026.07.14", + "package_version": "0.1.0-preview.1", + "api_version": "1.0", + "delivery_channel": "preview", + "lifecycle": "experimental" + } +} diff --git a/design-engineering/reference-profiles/governed-local/terminal/evidence/button.evidence.json b/design-engineering/reference-profiles/governed-local/terminal/evidence/button.evidence.json new file mode 100644 index 0000000..f9faa17 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/terminal/evidence/button.evidence.json @@ -0,0 +1,3504 @@ +{ + "claim_boundary": "Scenario artifacts are evidence, not visual regression baselines or accessibility certification.", + "component_id": "button", + "passes": [ + { + "artifact": { + "path": "runtime/terminal-action-disabled-busy.png", + "byte_length": 3599, + "media_type": "image/png", + "sha256": "sha256:d346be9e96750f636f29078d17d9871a04c2f4442b6849b029fa754c2795674e", + "height": 56, + "width": 640 + }, + "channel": "visual", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-disabled-busy-visual", + "recorded_at": "2026-07-14T06:33:49.115Z", + "result": { + "observed": "Recorded visual evidence for action-disabled-busy; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-disabled-busy", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "busy", + "disabled" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-action-disabled-busy.dom.json", + "byte_length": 8289, + "media_type": "application/json", + "sha256": "sha256:e775814ee40672cf5a86d312d6409a89ad24250ed04bdb3687a0c5bc4186e907" + }, + "channel": "dom", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-disabled-busy-dom", + "recorded_at": "2026-07-14T06:33:49.115Z", + "result": { + "observed": "Recorded dom evidence for action-disabled-busy; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-disabled-busy", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "busy", + "disabled" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-action-disabled-busy.ax.json", + "byte_length": 8208, + "media_type": "application/json", + "sha256": "sha256:07da1033e729a695631458754b9f53d8671b0e4b511c74372bc6543e4f352b50" + }, + "channel": "ax", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-disabled-busy-ax", + "recorded_at": "2026-07-14T06:33:49.115Z", + "result": { + "observed": "Recorded ax evidence for action-disabled-busy; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-disabled-busy", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "busy", + "disabled" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-action-focused.png", + "byte_length": 2470, + "media_type": "image/png", + "sha256": "sha256:39b96bc2459e2f0f442ce7b709f22e46b44f49b17c52d61940dff0246108ec48", + "height": 56, + "width": 640 + }, + "channel": "visual", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-focused-visual", + "recorded_at": "2026-07-14T06:33:49.228Z", + "result": { + "observed": "Recorded visual evidence for action-focused; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-focused", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "focus" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-action-focused.dom.json", + "byte_length": 8229, + "media_type": "application/json", + "sha256": "sha256:f71be6585f1ca78794f3baf6742bd25839e4ea9d6a2809b644aa2e2a811d30ae" + }, + "channel": "dom", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-focused-dom", + "recorded_at": "2026-07-14T06:33:49.228Z", + "result": { + "observed": "Recorded dom evidence for action-focused; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-focused", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "focus" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-action-focused.ax.json", + "byte_length": 8153, + "media_type": "application/json", + "sha256": "sha256:0192632a1c95628b3515536a9e90606ee04e1d217031a8f1c5358a97cc32cc4f" + }, + "channel": "ax", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-focused-ax", + "recorded_at": "2026-07-14T06:33:49.228Z", + "result": { + "observed": "Recorded ax evidence for action-focused; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-focused", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "focus" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-action-loading-busy.png", + "byte_length": 3660, + "media_type": "image/png", + "sha256": "sha256:05cbe173a8274cfa7e6aab05713d1cb925820a7412bffceadb94d83498c17747", + "height": 56, + "width": 640 + }, + "channel": "visual", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-loading-busy-visual", + "recorded_at": "2026-07-14T06:33:49.354Z", + "result": { + "observed": "Recorded visual evidence for action-loading-busy; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-loading-busy", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "busy", + "focus", + "loading" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-action-loading-busy.dom.json", + "byte_length": 8275, + "media_type": "application/json", + "sha256": "sha256:ca53cb29c53b267fc92cc68475fe4c2ea2c3dcccd31c1c6e858dc8a17198e358" + }, + "channel": "dom", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-loading-busy-dom", + "recorded_at": "2026-07-14T06:33:49.354Z", + "result": { + "observed": "Recorded dom evidence for action-loading-busy; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-loading-busy", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "busy", + "focus", + "loading" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-action-loading-busy.ax.json", + "byte_length": 8180, + "media_type": "application/json", + "sha256": "sha256:2ff34baf906c25aae5984939ac89a1b78464755dbd0bd8f2aa8313ec5eb6b357" + }, + "channel": "ax", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "action-loading-busy-ax", + "recorded_at": "2026-07-14T06:33:49.354Z", + "result": { + "observed": "Recorded ax evidence for action-loading-busy; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "action-loading-busy", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "action", + "state_set": [ + "busy", + "focus", + "loading" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-disclosure-expanded-loading.png", + "byte_length": 8556, + "media_type": "image/png", + "sha256": "sha256:d8c7ba056dd2a61ce6c85d8ec6bf56733cdc34da07678dd9b4175c7de9ab926a", + "height": 88, + "width": 640 + }, + "channel": "visual", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "disclosure-expanded-loading-visual", + "recorded_at": "2026-07-14T06:33:49.484Z", + "result": { + "observed": "Recorded visual evidence for disclosure-expanded-loading; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "disclosure-expanded-loading", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "disclosure", + "state_set": [ + "busy", + "expanded", + "focus", + "loading" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-disclosure-expanded-loading.dom.json", + "byte_length": 8332, + "media_type": "application/json", + "sha256": "sha256:1ddd7f1de3ad2c34d9e8d718c7b3128593a99e4207dce33ffb89f2fcc8f7d5b7" + }, + "channel": "dom", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "disclosure-expanded-loading-dom", + "recorded_at": "2026-07-14T06:33:49.484Z", + "result": { + "observed": "Recorded dom evidence for disclosure-expanded-loading; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "disclosure-expanded-loading", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "disclosure", + "state_set": [ + "busy", + "expanded", + "focus", + "loading" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-disclosure-expanded-loading.ax.json", + "byte_length": 8215, + "media_type": "application/json", + "sha256": "sha256:33371b0445ae51fc297e51103a8fd04060cc35dfd4c6abc08675463fee675c3e" + }, + "channel": "ax", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "disclosure-expanded-loading-ax", + "recorded_at": "2026-07-14T06:33:49.484Z", + "result": { + "observed": "Recorded ax evidence for disclosure-expanded-loading; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "disclosure-expanded-loading", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "disclosure", + "state_set": [ + "busy", + "expanded", + "focus", + "loading" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-toggle-focused-pressed.png", + "byte_length": 2070, + "media_type": "image/png", + "sha256": "sha256:838229be984b8bda1ebfe6095564f1093df18fdd2ca1e6c3d5b5c086162d4692", + "height": 56, + "width": 640 + }, + "channel": "visual", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "toggle-focused-pressed-visual", + "recorded_at": "2026-07-14T06:33:49.592Z", + "result": { + "observed": "Recorded visual evidence for toggle-focused-pressed; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "toggle-focused-pressed", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "toggle", + "state_set": [ + "focus", + "pressed" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-toggle-focused-pressed.dom.json", + "byte_length": 8269, + "media_type": "application/json", + "sha256": "sha256:5d16f2f75dddd358a1f9d9839045ae3bc73d9d9d029b4f76af8ae42d3469bab4" + }, + "channel": "dom", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "toggle-focused-pressed-dom", + "recorded_at": "2026-07-14T06:33:49.592Z", + "result": { + "observed": "Recorded dom evidence for toggle-focused-pressed; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "toggle-focused-pressed", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "toggle", + "state_set": [ + "focus", + "pressed" + ] + } + }, + { + "artifact": { + "path": "runtime/terminal-toggle-focused-pressed.ax.json", + "byte_length": 8181, + "media_type": "application/json", + "sha256": "sha256:596da3c726e836ad0f5a09f4c456981bb76627110b7f806f7cafad34f60ac55c" + }, + "channel": "ax", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "id": "toggle-focused-pressed-ax", + "recorded_at": "2026-07-14T06:33:49.592Z", + "result": { + "observed": "Recorded ax evidence for toggle-focused-pressed; this is not certification.", + "status": "passed" + }, + "run": { + "attempt": 1, + "id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "repository": "changeroa/StyleGallery", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "source": "local" + }, + "scenario_id": "toggle-focused-pressed", + "session": { + "attempt": 1, + "branch": "codex/roadmap-07-final-hardening", + "environment": { + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "kind": "browser", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", + "node": "v22.23.1", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + "nonce": "e9a2d4be58843c97ed9b5090c70f70e6db82510f84d2545ccb09e3cc3d6d46eb", + "receipt_sha256": "sha256:8d691f1a8c15f6d974e68a18281c206574207f34028e955bd3488890433c0cb2", + "revision": "017b96e26be60bfffb410c1994ab2acfd09beaaa", + "session_id": "8e81d617-4087-4350-9100-6e03798d1ff8", + "source": { + "files": [ + { + "byte_length": 1375, + "path": "consumer-reference/schema/ax-evidence.schema.json", + "sha256": "sha256:15e9e7f777c2929ac5a5474a5d94baef2dc6c1adb94bee5610fed488f00ea20f" + }, + { + "byte_length": 7291, + "path": "consumer-reference/schema/capture-session.schema.json", + "sha256": "sha256:cc3010fa76b06b3109417184cb30a98492ca2ad6aa56f4ba27505c1b228821f8" + }, + { + "byte_length": 7101, + "path": "consumer-reference/schema/component-state.schema.json", + "sha256": "sha256:606ef65acadf81c5b2769b1d79f9c1e870880a8fc2b72cbafa4f1c8c7f5e7e67" + }, + { + "byte_length": 1799, + "path": "consumer-reference/schema/dom-evidence.schema.json", + "sha256": "sha256:5b0e67d44f0592b729642e58580ddc7d3386c77a9cc61dd33e9f0538fcb3b850" + }, + { + "byte_length": 3807, + "path": "consumer-reference/schema/evidence-record.schema.json", + "sha256": "sha256:5f32dce2352a304d19eac923f538ad105edf04f7743128cb30eee07c9c071290" + }, + { + "byte_length": 1537, + "path": "consumer-reference/schema/fixture-manifest.schema.json", + "sha256": "sha256:05332488a36a546b2a1119d420e7cd3695e67f0544177734c46ba515784bc6e1" + }, + { + "byte_length": 1669, + "path": "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "sha256": "sha256:7a273cae646efff8652da92d52448d94dd93046feefd4b6db96b12332bb0ac94" + }, + { + "byte_length": 1620, + "path": "consumer-reference/schema/visual-evidence.schema.json", + "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" + }, + { + "byte_length": 60458, + "path": "package-lock.json", + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" + }, + { + "byte_length": 1625, + "path": "package.json", + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" + }, + { + "byte_length": 532, + "path": "playwright.config.mjs", + "sha256": "sha256:7dcc2ae21602ac89af47d2bbf122f5945098f0e8fda9d47db7d33980592e60ae" + }, + { + "byte_length": 3069, + "path": "profiles/editorial/components/button.component.json", + "sha256": "sha256:fb3888e451783aba0c6ac86abcb7d4f361b6c88904f66f412551afea62f33f67" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/fixtures/button.fixture.json", + "sha256": "sha256:9f4f1971d44b5d3d432658d0e15321536ed2741d71e368bb80cfdd4413ed3779" + }, + { + "byte_length": 340, + "path": "profiles/editorial/local-foundations.json", + "sha256": "sha256:467f948969076ccd1226cafa069741d3a693ff66357f610d9c6eda7554bdcf8f" + }, + { + "byte_length": 1434, + "path": "profiles/editorial/profile.json", + "sha256": "sha256:9f22cfc4306ee1002017762915aa6ff8ce1400868a1e1e00e1353015ba8c4997" + }, + { + "byte_length": 5710, + "path": "profiles/editorial/states/button.states.json", + "sha256": "sha256:7217f3af17af24d7fde05e4e6207b7234e80bb431415ff8095450c33e91b8560" + }, + { + "byte_length": 608, + "path": "profiles/editorial/tokens.dtcg.json", + "sha256": "sha256:807c2f1ed2db489b4ffa3ee86b991f5b34decdc279dfd0c75f29b4b65d86c34f" + }, + { + "byte_length": 3068, + "path": "profiles/terminal/components/button.component.json", + "sha256": "sha256:fd9ce393498649125b7e9490f9f00ab95b14ddd59762076af599d22887daa42a" + }, + { + "byte_length": 1441, + "path": "profiles/terminal/fixtures/button.fixture.json", + "sha256": "sha256:c56f157ed0c297da747c1a352ede820cfe493d4617d52977dbd506e9e05b2332" + }, + { + "byte_length": 343, + "path": "profiles/terminal/local-foundations.json", + "sha256": "sha256:a91a72ae29cab8eeef6d3d2fff6b0917246e0a2d478410eca8eeef73db414b8c" + }, + { + "byte_length": 1432, + "path": "profiles/terminal/profile.json", + "sha256": "sha256:26194b0094eac1f721679cbe43794c9372480cb916a12ba6ede0558e1bd2ff55" + }, + { + "byte_length": 5709, + "path": "profiles/terminal/states/button.states.json", + "sha256": "sha256:b56f534a904bff67a41f1cb015ea22b420318a1a7b2421ad6f403b027d582bbf" + }, + { + "byte_length": 606, + "path": "profiles/terminal/tokens.dtcg.json", + "sha256": "sha256:2fe2ac25708c77efcc662b9bbec9147624cd11f3711d70e96ff604e9602d91c2" + }, + { + "byte_length": 741, + "path": "scripts/artifact-metadata.mjs", + "sha256": "sha256:060a67cd2ae1412b950509bce72fbba8d0eabbbeba0fdf3d06edc1790ac01f6e" + }, + { + "byte_length": 7686, + "path": "scripts/capture-session-contract.mjs", + "sha256": "sha256:59ab740e9ceb39e1665805dd789746d91977fe190a7868e08a6f8fd57f43863f" + }, + { + "byte_length": 5181, + "path": "scripts/component-state-contract.mjs", + "sha256": "sha256:4de341b2b4557ab9a91fd35c7604ef915071a41394ccc86a7d0395a3f84f2381" + }, + { + "byte_length": 12400, + "path": "scripts/component-state-semantics.mjs", + "sha256": "sha256:7c51786051d8a1cdc0d09e77d2c06845dd8f21e36a68dbb8c09953c9eb768ae5" + }, + { + "byte_length": 5426, + "path": "scripts/create-component-state-session.mjs", + "sha256": "sha256:1b56b74573af591b917dcf36ca147d6e4e8d4e1ebcb01a77ae2c6637ae311eae" + }, + { + "byte_length": 6909, + "path": "scripts/profile-record-contract.mjs", + "sha256": "sha256:b216e6a939362198bfc0628d0856842891c8e2e5be02b0b298110424de1d8bf7" + }, + { + "byte_length": 1592, + "path": "scripts/strict-json.mjs", + "sha256": "sha256:d203dab02d4fb74866663bdfd7bc58beb120bd5d0e8e145b3bfe62c1bc5091a3" + }, + { + "byte_length": 953, + "path": "scripts/visual-expectation-contract.mjs", + "sha256": "sha256:4279a66c607ff3eb99fa86054fcc27658f93108d80c0cdadf9ceb40bb79cc62c" + }, + { + "byte_length": 9388, + "path": "tests/component-state-evidence.spec.mjs", + "sha256": "sha256:f10bfb2cf7ea37f07ca1af6c160a33b22cb5bcf31eeb60f338f121f1cdc057fa" + }, + { + "byte_length": 7039, + "path": "tests/helpers/render-component-state.mjs", + "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" + } + ], + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" + }, + "started_at": "2026-07-14T06:33:47.214Z" + }, + "scope": { + "component": "button", + "semantic_mode": "toggle", + "state_set": [ + "focus", + "pressed" + ] + } + } + ], + "profile_id": "terminal-reference-profile", + "schema_version": "1.0" +} diff --git a/design-engineering/reference-profiles/governed-local/terminal/fixtures/button.fixture.json b/design-engineering/reference-profiles/governed-local/terminal/fixtures/button.fixture.json new file mode 100644 index 0000000..2df938c --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/terminal/fixtures/button.fixture.json @@ -0,0 +1,65 @@ +{ + "schema_version": "1.0", + "fixture_id": "terminal-button-runtime", + "component_id": "button", + "profile_id": "terminal-reference-profile", + "component_record": "../components/button.component.json", + "state_record": "../states/button.states.json", + "scenarios": [ + { + "id": "action-disabled-busy", + "semantic_mode": "action", + "label": "Command unavailable", + "activation_key": "Enter", + "required_channels": [ + "visual", + "dom", + "ax" + ] + }, + { + "id": "action-focused", + "semantic_mode": "action", + "label": "Run command", + "activation_key": "Enter", + "required_channels": [ + "visual", + "dom", + "ax" + ] + }, + { + "id": "action-loading-busy", + "semantic_mode": "action", + "label": "Command running", + "activation_key": "Enter", + "required_channels": [ + "visual", + "dom", + "ax" + ] + }, + { + "id": "disclosure-expanded-loading", + "semantic_mode": "disclosure", + "label": "Show process log", + "activation_key": "Enter", + "required_channels": [ + "visual", + "dom", + "ax" + ] + }, + { + "id": "toggle-focused-pressed", + "semantic_mode": "toggle", + "label": "Pin output", + "activation_key": "Space", + "required_channels": [ + "visual", + "dom", + "ax" + ] + } + ] +} diff --git a/design-engineering/reference-profiles/governed-local/terminal/generated/evidence-coverage.md b/design-engineering/reference-profiles/governed-local/terminal/generated/evidence-coverage.md new file mode 100644 index 0000000..04fc7ba --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/terminal/generated/evidence-coverage.md @@ -0,0 +1,23 @@ +--- +type: Generated Evidence +title: "terminal button evidence coverage" +description: "Claim-scoped evidence channels and artifacts derived from canonical JSON." +--- + + +# terminal button evidence coverage + +Claim boundary: Scenario artifacts are evidence, not visual regression baselines or accessibility certification. + +| Scenario | Required channels | Recorded channels | Pass records | Artifacts | +| --- | --- | --- | --- | --- | +| action-disabled-busy | visual, dom, ax | ax, dom, visual | 3 | runtime/terminal-action-disabled-busy.ax.json
runtime/terminal-action-disabled-busy.dom.json
runtime/terminal-action-disabled-busy.png | +| action-focused | visual, dom, ax | ax, dom, visual | 3 | runtime/terminal-action-focused.ax.json
runtime/terminal-action-focused.dom.json
runtime/terminal-action-focused.png | +| action-loading-busy | visual, dom, ax | ax, dom, visual | 3 | runtime/terminal-action-loading-busy.ax.json
runtime/terminal-action-loading-busy.dom.json
runtime/terminal-action-loading-busy.png | +| disclosure-expanded-loading | visual, dom, ax | ax, dom, visual | 3 | runtime/terminal-disclosure-expanded-loading.ax.json
runtime/terminal-disclosure-expanded-loading.dom.json
runtime/terminal-disclosure-expanded-loading.png | +| toggle-focused-pressed | visual, dom, ax | ax, dom, visual | 3 | runtime/terminal-toggle-focused-pressed.ax.json
runtime/terminal-toggle-focused-pressed.dom.json
runtime/terminal-toggle-focused-pressed.png | + +## IA Navigation + +Parent: [Keyboard Matrix](keyboard-matrix.md). +Next: [Reference Profiles](../../../index.md). diff --git a/design-engineering/reference-profiles/governed-local/terminal/generated/keyboard-matrix.md b/design-engineering/reference-profiles/governed-local/terminal/generated/keyboard-matrix.md new file mode 100644 index 0000000..fc8c83f --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/terminal/generated/keyboard-matrix.md @@ -0,0 +1,19 @@ +--- +type: Generated Evidence +title: "terminal button keyboard matrix" +description: "Keyboard behavior and live fixture coverage derived from canonical JSON." +--- + + +# terminal button keyboard matrix + +| Mode | Element | Role | Keys | Behavior | Runtime scenario | +| --- | --- | --- | --- | --- | --- | +| action | button | button | Enter, Space | invoke | action-loading-busy | +| disclosure | button | button | Enter, Space | control | disclosure-expanded-loading | +| toggle | button | button | Enter, Space | toggle | toggle-focused-pressed | + +## IA Navigation + +Parent: [State Matrix](state-matrix.md). +Next: [Evidence Coverage](evidence-coverage.md). diff --git a/design-engineering/reference-profiles/governed-local/terminal/generated/state-matrix.md b/design-engineering/reference-profiles/governed-local/terminal/generated/state-matrix.md new file mode 100644 index 0000000..7169d87 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/terminal/generated/state-matrix.md @@ -0,0 +1,21 @@ +--- +type: Generated Evidence +title: "terminal button state matrix" +description: "Compound state sets and channel expectations derived from canonical JSON." +--- + + +# terminal button state matrix + +| Scenario | Mode | State set | ARIA | Visual | Activation | +| --- | --- | --- | --- | --- | --- | +| action-disabled-busy | action | busy, disabled | aria-busy=true
aria-disabled=true | busy, disabled | suppressed | +| action-focused | action | focus | none | focus, ready | allowed | +| action-loading-busy | action | busy, focus, loading | aria-busy=true | busy, focus, loading | suppressed | +| disclosure-expanded-loading | disclosure | busy, expanded, focus, loading | aria-busy=true
aria-expanded=true | busy, expanded, focus, loading | allowed | +| toggle-focused-pressed | toggle | focus, pressed | aria-pressed=true | focus, pressed | allowed | + +## IA Navigation + +Parent: [Governed Local Profiles](../../index.md). +Next: [Keyboard Matrix](keyboard-matrix.md). diff --git a/design-engineering/reference-profiles/governed-local/terminal/local-foundations.json b/design-engineering/reference-profiles/governed-local/terminal/local-foundations.json new file mode 100644 index 0000000..71107c5 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/terminal/local-foundations.json @@ -0,0 +1,12 @@ +{ + "bindings": { + "accent": "{color.accent}", + "body_font_family": "SFMono-Regular, Consolas, monospace", + "canvas": "{color.screen}", + "content_rhythm": "{space.rhythm}", + "text": "{color.ink}" + }, + "identity": "Dark high-density operator terminal", + "profile_id": "terminal-reference-profile", + "schema_version": "1.0" +} diff --git a/design-engineering/reference-profiles/governed-local/terminal/profile.json b/design-engineering/reference-profiles/governed-local/terminal/profile.json new file mode 100644 index 0000000..cdeb352 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/terminal/profile.json @@ -0,0 +1,43 @@ +{ + "artifact_mode": "governed_local", + "component_records": ["components/button.component.json"], + "default": false, + "environment_assumptions": { + "reset": { + "body_margin": "0", + "box_sizing": "border-box", + "figure_margin": "0" + }, + "user_agent_styles": "Preserve semantic browser defaults except for the declared reset." + }, + "example_only": true, + "evidence_records": ["evidence/button.evidence.json"], + "fixture_independence": "related", + "fixture_records": ["fixtures/button.fixture.json"], + "generated_records": ["generated/evidence-coverage.md", "generated/keyboard-matrix.md", "generated/state-matrix.md"], + "handoff": { + "record": "design-engineering/reference-profiles/governed-local/terminal/profile.json", + "status": "declared" + }, + "id": "terminal-reference-profile", + "layout_source_sha": "775430bbaf4ee208a642220f440f6926d79c90a3", + "local_foundations": "local-foundations.json", + "maturity": "experimental", + "owner": { + "enforcement": "placeholder", + "name": "Design Engineering owner" + }, + "profile_kind": "governed_local", + "related_fixture_set_id": "layout-identity-adversarial-pair", + "review_independence": "single_account", + "schema_version": "1.0", + "selection": { + "method": "profile_path", + "required": true + }, + "support": { + "status": "active" + }, + "state_records": ["states/button.states.json"], + "tokens": "tokens.dtcg.json" +} diff --git a/design-engineering/reference-profiles/governed-local/terminal/states/button.states.json b/design-engineering/reference-profiles/governed-local/terminal/states/button.states.json new file mode 100644 index 0000000..830f6d4 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/terminal/states/button.states.json @@ -0,0 +1,195 @@ +{ + "schema_version": "1.0", + "record_kind": "state_set", + "component_id": "button", + "profile_id": "terminal-reference-profile", + "visual_environments": [ + { + "id": "darwin-arm64-chromium-1228", + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "local-host:25.5.0", + "platform": "darwin/arm64", + "playwright": "1.61.0", + "viewport": "1024x768" + }, + { + "id": "linux-amd64-chromium-1228", + "browser": "Google Chrome for Testing 149.0.7827.55", + "browser_revision": "chromium-1228", + "container_image": "mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a", + "platform": "linux/amd64", + "playwright": "1.61.0", + "viewport": "1024x768" + } + ], + "scenarios": [ + { + "id": "action-disabled-busy", + "semantic_mode": "action", + "states": [ + "busy", + "disabled" + ], + "aria": { + "busy": "true", + "disabled": "true" + }, + "expected": { + "visual": [ + "busy", + "disabled" + ], + "visual_image": [ + { "environment_id": "darwin-arm64-chromium-1228", "sha256": "sha256:d346be9e96750f636f29078d17d9871a04c2f4442b6849b029fa754c2795674e", "width": 640, "height": 56 }, + { "environment_id": "linux-amd64-chromium-1228", "sha256": "sha256:f28ac192821773d5d692ad978075da5b1480971e3b3bc9be8983e3eb2cd43484", "width": 640, "height": 56 } + ], + "dom": { + "active": "false", + "aria-busy": "true", + "disabled": "true", + "role": "button" + }, + "ax": { + "busy": true, + "disabled": true, + "focused": false, + "role": "button" + }, + "activation": "suppressed" + } + }, + { + "id": "action-focused", + "semantic_mode": "action", + "states": [ + "focus" + ], + "aria": {}, + "expected": { + "visual": [ + "focus", + "ready" + ], + "visual_image": [ + { "environment_id": "darwin-arm64-chromium-1228", "sha256": "sha256:39b96bc2459e2f0f442ce7b709f22e46b44f49b17c52d61940dff0246108ec48", "width": 640, "height": 56 }, + { "environment_id": "linux-amd64-chromium-1228", "sha256": "sha256:7497f0542e2a4b77749ee47cfa7f575b640bad737c652a71610e9f86f8e5f2b8", "width": 640, "height": 56 } + ], + "dom": { + "active": "true", + "role": "button" + }, + "ax": { + "focused": true, + "role": "button" + }, + "activation": "allowed" + } + }, + { + "id": "action-loading-busy", + "semantic_mode": "action", + "states": [ + "busy", + "focus", + "loading" + ], + "aria": { + "busy": "true" + }, + "expected": { + "visual": [ + "busy", + "focus", + "loading" + ], + "visual_image": [ + { "environment_id": "darwin-arm64-chromium-1228", "sha256": "sha256:05cbe173a8274cfa7e6aab05713d1cb925820a7412bffceadb94d83498c17747", "width": 640, "height": 56 }, + { "environment_id": "linux-amd64-chromium-1228", "sha256": "sha256:1521023b829210f899d2f56750e362cbfc30e8d6045f6f78a6c553a43bf610c4", "width": 640, "height": 56 } + ], + "dom": { + "active": "true", + "aria-busy": "true", + "role": "button" + }, + "ax": { + "busy": true, + "focused": true, + "role": "button" + }, + "activation": "suppressed" + } + }, + { + "id": "disclosure-expanded-loading", + "semantic_mode": "disclosure", + "states": [ + "busy", + "expanded", + "focus", + "loading" + ], + "aria": { + "busy": "true", + "expanded": "true" + }, + "expected": { + "visual": [ + "busy", + "expanded", + "focus", + "loading" + ], + "visual_image": [ + { "environment_id": "darwin-arm64-chromium-1228", "sha256": "sha256:d8c7ba056dd2a61ce6c85d8ec6bf56733cdc34da07678dd9b4175c7de9ab926a", "width": 640, "height": 88 }, + { "environment_id": "linux-amd64-chromium-1228", "sha256": "sha256:7cef4e68a5a3df50c1705c7b88936bf0988317e2f6bed92ae5c04992388126a3", "width": 640, "height": 91 } + ], + "dom": { + "active": "true", + "aria-busy": "true", + "aria-expanded": "true", + "role": "button" + }, + "ax": { + "busy": true, + "expanded": true, + "focused": true, + "role": "button" + }, + "activation": "allowed" + } + }, + { + "id": "toggle-focused-pressed", + "semantic_mode": "toggle", + "states": [ + "focus", + "pressed" + ], + "aria": { + "pressed": "true" + }, + "expected": { + "visual": [ + "focus", + "pressed" + ], + "visual_image": [ + { "environment_id": "darwin-arm64-chromium-1228", "sha256": "sha256:838229be984b8bda1ebfe6095564f1093df18fdd2ca1e6c3d5b5c086162d4692", "width": 640, "height": 56 }, + { "environment_id": "linux-amd64-chromium-1228", "sha256": "sha256:1c105821f16a028e974bdf00590e3d9aee8042016ee341f4efe56b4a525b867d", "width": 640, "height": 56 } + ], + "dom": { + "active": "true", + "aria-pressed": "true", + "role": "button" + }, + "ax": { + "focused": true, + "pressed": true, + "role": "button" + }, + "activation": "allowed" + } + } + ] +} diff --git a/design-engineering/reference-profiles/governed-local/terminal/tokens.dtcg.json b/design-engineering/reference-profiles/governed-local/terminal/tokens.dtcg.json new file mode 100644 index 0000000..61c5f59 --- /dev/null +++ b/design-engineering/reference-profiles/governed-local/terminal/tokens.dtcg.json @@ -0,0 +1,17 @@ +{ + "color": { + "$type": "color", + "accent": { "$value": { "alpha": 1, "colorSpace": "srgb", "components": [0.25, 0.92, 0.55] } }, + "ink": { "$value": { "alpha": 1, "colorSpace": "srgb", "components": [0.76, 0.95, 0.83] } }, + "screen": { "$value": { "alpha": 1, "colorSpace": "srgb", "components": [0.02, 0.05, 0.04] } } + }, + "duration": { + "$type": "duration", + "feedback": { "$value": { "unit": "ms", "value": 80 } } + }, + "space": { + "$type": "dimension", + "page": { "$value": { "unit": "rem", "value": 1 } }, + "rhythm": { "$value": { "unit": "rem", "value": 0.5 } } + } +} diff --git a/design-engineering/reference-profiles/index.md b/design-engineering/reference-profiles/index.md new file mode 100644 index 0000000..c1943da --- /dev/null +++ b/design-engineering/reference-profiles/index.md @@ -0,0 +1,19 @@ +# Design Engineering Reference Profiles + +Domain classification: design-engineering. + +These experimental examples challenge the consumer-reference contract with intentionally different identities over the same pinned Layout source. They are related adversarial fixtures, not defaults, canonical guidance, production consumers, or independent adoption evidence. + +## Routes + +- [Governed Local Profiles](governed-local/index.md) explains the two explicitly selected repository-owned examples. +- [External Adaptation](external-adaptation/index.md) explains how external consumers retain their records without creating an in-repository adopter record. + +## Ownership Boundary + +Design Engineering owns profile identity and values. Consumer Reference validates shape and provenance without owning those values. Layout remains upstream and cannot import these records. + +## IA Navigation + +Parent: [Design Engineering](../index.md). +Next: [Governed Local Profiles](governed-local/index.md). diff --git a/guides/documentation-mode-taxonomy.md b/guides/documentation-mode-taxonomy.md index eec5556..beb6561 100644 --- a/guides/documentation-mode-taxonomy.md +++ b/guides/documentation-mode-taxonomy.md @@ -37,6 +37,7 @@ Do not use `mixed` as a mode. If a page has more than one reading posture, keep | `motion/*.md` | Reference or How-to | Explanation | Apply experimental motion terminology and review guidance inside its evidence boundary. | | `design-engineering/index.md` | Reference | Explanation | Enter Design Engineering product-level decision guidance. | | `design-engineering/*.md` | Explanation | How-to | Apply experimental practitioner methods without treating taste as proof. | +| `design-engineering/consumer-migration-readiness.md` | How-to | Policy/procedure | Build a consumer-owned migration evidence chain without claiming universal authority. | | `platform-guides/index.md` | Reference | Explanation | Enter bounded comparative platform references. | | `platform-guides/*.md` | Explanation | Reference | Compare a named platform with explicit source, version, adaptation, and evidence limits. | | `guides/decision-tree.md` | How-to | Reference | Route from constraints to pattern families and recipes. | @@ -73,11 +74,13 @@ Do not use `mixed` as a mode. If a page has more than one reading posture, keep | `quality/gates/accessibility-evidence.md` | Policy/procedure | Reference | Decide whether accessibility evidence supports a claim. | | `quality/gates/design-claim.md` | Policy/procedure | Reference | Decide whether a non-layout design claim is admissible. | | `quality/gates/harmony-evaluation.md` | Policy/procedure | Reference | Decide whether a webpage composition can guide implementation. | +| `quality/gates/consumer-migration-evidence.md` | Policy/procedure | Reference | Decide whether declared consumer migration evidence is sufficient. | | `quality/gates/layout.md` | Policy/procedure | Reference | Decide whether a layout claim satisfies the pattern contract. | | `quality/gates/rationale.md` | Policy/procedure | Reference | Decide whether a rationale exposes options, warrant, and debt. | | `quality/gates/visual-evidence.md` | Policy/procedure | Reference | Decide whether rendered evidence supports a visual claim. | | `quality/evidence/index.md` | Reference | None | Look up evidence reference families. | | `quality/evidence/accessibility.md` | Reference | Explanation | Understand accessibility evidence boundaries. | +| `quality/evidence/consumer-migration.md` | Reference | Explanation | Understand runtime, browser, session, adoption, freshness, and review boundaries for a migration. | | `quality/evidence/design-rationale.md` | Reference | Explanation | Understand design-rationale evidence boundaries. | | `quality/evidence/executable-evidence.md` | Reference | Policy/procedure | Map validators, tests, rendered QA, review, and source citations to claim boundaries. | | `quality/evidence/families.md` | Reference | Explanation | Look up normalized evidence-family names and boundaries. | @@ -123,6 +126,7 @@ The wildcard rows are intentional. Generated pattern pages share one stable refe | Choose the owning StyleGallery domain. | `DOMAINS.md` | The selected domain `index.md` | | Name or review product motion. | `motion/index.md` | The matching Motion reference or workflow | | Review a product-level craft decision. | `design-engineering/index.md` | `design-engineering/interface-craft.md`, then the relevant quality gate | +| Prove an existing consumer migration. | `design-engineering/consumer-migration-readiness.md` | `quality/gates/consumer-migration-evidence.md`, then the consumer-owned conformance record | | Compare an Apple interaction convention. | `platform-guides/index.md` | `platform-guides/apple-interaction.md` and current official sources | | Choose a layout when the pattern name is unknown. | `GUIDE.md` | `guides/decision-tree.md` | | Turn raw content into a homepage or webpage. | `guides/webpage-generation-workflow.md` | `recipes/homepage.md`, `quality/gates/harmony-evaluation.md` | diff --git a/guides/layout-brief.md b/guides/layout-brief.md index 4ecadad..4fc09c0 100644 --- a/guides/layout-brief.md +++ b/guides/layout-brief.md @@ -85,6 +85,7 @@ Implementation handoff: - Which viewport, content-stress, accessibility, and visual-evidence checks must pass before shipping? - Consumer reference: `not_applicable`. - Consumer reference reason: This blank planning template declares no consumer-specific reference record. +- Consumer migration conformance: omit for ordinary work; for an elected migration method, use `declared` plus one normalized consumer-local JSON record path. ## Minimum Required Fields By Use Case diff --git a/guides/webpage-generation-workflow.md b/guides/webpage-generation-workflow.md index 353abfa..d33a3f5 100644 --- a/guides/webpage-generation-workflow.md +++ b/guides/webpage-generation-workflow.md @@ -180,4 +180,6 @@ Accepted debt: Every handoff keeps the consumer-reference field. Replace the default `not_applicable` pair only when the consuming repository has one canonical record; then use `Consumer reference: declared` and a normalized repository-relative `.json` path under that repository root. See the [Consumer Reference Receiver Contract](../consumer-reference/contract.md). +Do not add a migration-conformance field to ordinary webpage work. When an existing consumer is explicitly being migrated, add `Consumer migration conformance: declared` and a normalized consumer-local JSON path, then follow [Consumer Migration Readiness](../design-engineering/consumer-migration-readiness.md). This extension does not change the ordinary `not_applicable` default. + The implementer builds from semantic HTML and layout patterns first, then applies product-level visual styling from the image reference outside reusable pattern CSS. The final page must still pass layout stress checks, accessibility evidence, and any visual-evidence protocol declared by the consuming product. diff --git a/index.md b/index.md index a83f5d0..974e5a7 100644 --- a/index.md +++ b/index.md @@ -17,6 +17,7 @@ Primary role: OKF bundle map. - [Game UI](game-ui/index.md) - Game-interface classification, hierarchy, reference records, and named engine implementation guides. - [Platform Guides](platform-guides/index.md) - Bounded comparative references for named platforms. - [Consumer reference](consumer-reference/index.md) - Shared non-domain receiver contract for declared repository-local JSON records or reasoned non-applicability. +- [Consumer migration readiness](design-engineering/consumer-migration-readiness.md) - Experimental Design Engineering method for consumer-owned conformance, runtime proof, and source-bound page evidence. - [Repository guide](README.md) - StyleGallery domain routes, shared policy, and Layout authoring principles. - [Layout planning guide](GUIDE.md) - Pre-design entry point for choosing and composing StyleGallery Layout patterns. - [Webpage generation workflow](guides/webpage-generation-workflow.md) - Intake-to-implementation workflow for homepage and webpage creation. @@ -30,4 +31,5 @@ Primary role: OKF bundle map. - [Layout pattern catalog](CATALOG.md) - Generated index of Layout-domain patterns. - [Pattern categories](patterns/index.md) - Category index for generated layout patterns. - [Quality gates](quality/index.md) - Principle-backed gates for admissible layout and design evidence claims. +- [Consumer migration evidence gate](quality/gates/consumer-migration-evidence.md) - Blocking contract for declared migration evidence. - [Update log](log.md) - Bundle update history. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c66f779 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1738 @@ +{ + "name": "stylegallery-validation", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stylegallery-validation", + "dependencies": { + "style-dictionary": "5.5.0" + }, + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@playwright/test": "1.61.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@bundled-es-modules/deepmerge": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.2.tgz", + "integrity": "sha512-q8doe7ndrY2IolUOFIn0A0++JBX38pMhN7kFhTF4cnjIcILf6X6H2yWczInyv8ZFdR0lrE8088X8XS5efxXz8A==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1" + } + }, + "node_modules/@bundled-es-modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-x9nR2e1pt8LF0yLPC6yz/aUoiN7qJJwZ1znLxIXCxGyH+8BI+yO/sklBdn1+QbUyWXQBM+CjfZz3IhqtgIoDVg==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "events": "^3.3.0", + "glob": "^13.0.6", + "path": "^0.12.7", + "stream": "^0.0.3", + "string_decoder": "^1.3.0", + "url": "^0.11.4" + } + }, + "node_modules/@bundled-es-modules/memfs": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/memfs/-/memfs-4.17.0.tgz", + "integrity": "sha512-ykdrkEmQr9BV804yd37ikXfNnvxrwYfY9Z2/EtMHFEFadEjsQXJ1zL9bVZrKNLDtm91UdUOEHso6Aweg93K6xQ==", + "license": "Apache-2.0", + "dependencies": { + "assert": "^2.1.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "memfs": "^4.17.0", + "path": "^0.12.7", + "stream": "^0.0.3", + "util": "^0.12.5" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.64.0.tgz", + "integrity": "sha512-zs2TAq7Six5jgMuoMNjpspAvOP3mhtgq/k1UyQodEzCtQi/N83y2/y+zcvnZSGp/Rxq96DBN+bValOBQAyn/ew==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.64.0.tgz", + "integrity": "sha512-nMWOVbkLFyEgmXZih3wyvxA9XpgyyqyfrINMHvEFqhi7uqfRl7c9ERJt6yX7vgMPrB9Uo+OJO+Spa0cFzPD01w==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.64.0.tgz", + "integrity": "sha512-dO+NNkODbUli4uV42bcNrrLvq5rE7SNpdZ5TNd0dtbLsAaNK3MDiIC9lUi+brboGoIjW6vd2fB1qao60nrk5xA==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "@jsonjoy.com/fs-print": "4.64.0", + "@jsonjoy.com/fs-snapshot": "4.64.0", + "glob-to-regex.js": "^1.0.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.64.0.tgz", + "integrity": "sha512-/o7WRFhUWaM/fOrslwLZGnzn4RmRILykn+lAL+mNObqqRNw+CQSiij6hpCeZ+C7buhdoVo7go/OYqzaSUfDYmA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.64.0.tgz", + "integrity": "sha512-WDD9WVs0hb7UAEKTgZW2f66WDrbj7gIIWwpP3spbLyXa0rghtUaFTB8L4gdR3ZCWwiKIsj38/CNijpVmpnuPUw==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.64.0.tgz", + "integrity": "sha512-k5Indsx9hWW9xSF7Y6oSKKwtCUNhzZxadub3owhIlitc+iMRVlPPdX2duTKQWBL3qNWpXya8jykgaaWpheeS4w==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "glob-to-regex.js": "^1.0.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.64.0.tgz", + "integrity": "sha512-PHZFccchvkhWrwPWHjmVAhbC3vSHCtyZvlZfJJ3ho2bnzl450hXri6/8e6pbkWdH+SkmLXNml0sV8e5HDAfxKw==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.64.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.64.0.tgz", + "integrity": "sha512-oM7UDeL83q6NBzzsfKAsYKXKVXlykKFqqOLh4xZZKAzzROTlInkPbc6LTDGThEOnPiFiUzA7tYziHG9xavd76Q==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.26", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", + "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "license": "MIT" + }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/component-emitter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-2.0.0.tgz", + "integrity": "sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memfs": { + "version": "4.64.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.64.0.tgz", + "integrity": "sha512-Kw72fgY7Wn+sD8KmtNWSafl1dz0UvAsE/PHs3YVfLiaZuA3HxNm9sRLqAu0ATiBGJvME1PxZXbBZPv5GycDeAw==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.64.0", + "@jsonjoy.com/fs-fsa": "4.64.0", + "@jsonjoy.com/fs-node": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-to-fsa": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "@jsonjoy.com/fs-print": "4.64.0", + "@jsonjoy.com/fs-snapshot": "4.64.0", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-unified": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/path-unified/-/path-unified-0.2.0.tgz", + "integrity": "sha512-MNKqvrKbbbb5p7XHXV6ZAsf/1f/yJQa13S/fcX0uua8ew58Tgc6jXV+16JyAbnR/clgCH+euKDxrF2STxMHdrg==", + "license": "MIT" + }, + "node_modules/path/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "license": "ISC" + }, + "node_modules/path/node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stream": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.3.tgz", + "integrity": "sha512-aMsbn7VKrl4A2T7QAQQbzgN7NVc70vgF5INQrBXqn4dCXN1zy3L9HGgLO5s7PExmdrzTJ8uR/27aviW8or8/+A==", + "license": "MIT", + "dependencies": { + "component-emitter": "^2.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/style-dictionary": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/style-dictionary/-/style-dictionary-5.5.0.tgz", + "integrity": "sha512-AGkOZtAc3OTz99wlzstrmj5OM5BWOW2IbmXD74sf0MXFPi271TGBdywokgd7bS3L0tKOk9M0FR+R9gnbXRSSfg==", + "license": "Apache-2.0", + "dependencies": { + "@bundled-es-modules/deepmerge": "^4.3.2", + "@bundled-es-modules/glob": "^13.0.6", + "@bundled-es-modules/memfs": "^4.17.0", + "@zip.js/zip.js": "^2.7.44", + "chalk": "^5.3.0", + "change-case": "^5.3.0", + "colorjs.io": "^0.5.2", + "commander": "^12.1.0", + "is-plain-obj": "^4.1.0", + "json5": "^2.2.2", + "path-unified": "^0.2.0", + "prettier": "^3.3.3", + "tinycolor2": "^1.6.0" + }, + "bin": { + "style-dictionary": "bin/style-dictionary.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/thingies": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", + "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2f0dc34 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "stylegallery-validation", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "audit:evidence": "node scripts/audit-evidence-freshness.mjs", + "build": "node scripts/build-reference-artifacts.mjs --adapter style-dictionary --fail-on-warning --json", + "create:component-state-session": "node scripts/create-component-state-session.mjs", + "test": "node scripts/test-reference-adapters.mjs --json", + "test:consumer-conformance": "playwright test tests/consumer-conformance.spec.mjs", + "test:component-state": "node scripts/test-component-state-source-contract.mjs && node scripts/test-validate-component-state.mjs --json && node scripts/test-validate-component-state-artifacts.mjs && node scripts/test-generate-consumer-reference-evidence.mjs --json", + "test:component-state:runtime": "playwright test tests/component-state-evidence.spec.mjs --project=chromium", + "test:component-state:runtime-negative": "node scripts/test-component-state-sentinel.mjs", + "test:sentinel": "playwright test tests/consumer-reference-sentinels.spec.mjs --project=chromium", + "validate": "node scripts/validate-reference-artifacts.mjs --manifest consumer-reference/generated/manifest.json --json", + "validate:component-state": "node scripts/validate-component-state.mjs --json && node scripts/generate-consumer-reference-evidence.mjs --check --json" + }, + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@playwright/test": "1.61.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1" + }, + "dependencies": { + "style-dictionary": "5.5.0" + } +} diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..12a8b4f --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,22 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + expect: { + toHaveScreenshot: { + animations: "disabled", + caret: "hide", + threshold: 0, + }, + }, + fullyParallel: false, + projects: [{ name: "chromium", use: { browserName: "chromium" } }], + reporter: [["line"]], + snapshotPathTemplate: "{testDir}/snapshots/{arg}{ext}", + testDir: "./tests", + use: { + actionTimeout: 10_000, + trace: "retain-on-failure", + viewport: { height: 768, width: 1024 }, + }, + workers: 1, +}); diff --git a/quality/evidence/consumer-migration.md b/quality/evidence/consumer-migration.md new file mode 100644 index 0000000..355fe6a --- /dev/null +++ b/quality/evidence/consumer-migration.md @@ -0,0 +1,42 @@ +--- +type: Evidence Reference +title: Consumer Migration Evidence +description: Evidence channels and claim boundaries for consumer migration readiness. +--- + +# Consumer Migration Evidence + +Consumer migration evidence is a linked chain, not a single screenshot or successful validator invocation. + +## Evidence Channels + +| Channel | Supports | Does not support by itself | +| --- | --- | --- | +| Conformance schema and validator | The record is closed, all thirteen dimensions are explicit, paths are safe, and references are internally consistent. | The behavior inventory is complete or the product is correct. | +| Unit and integration scenarios | Named observable behavior passed at the pinned source digest and zero exit. | Browser layout, visual quality, or untested behavior. | +| Browser matrix | Named viewport, container, content, state, overlay, page-scale, focus, overflow, contrast, and semantic assertions passed in the executed browser. | Complete accessibility, usability, or cross-browser equivalence. | +| Page-evidence session | Artifacts came from one source-bound session and match the finalized manifest hashes and identities. | Independent provenance, design approval, or truth outside the declared run. | +| Adoption mapping | A pinned StyleGallery anchor was interpreted as a named local decision for a concrete consumer target. | Universal adoption or permission to import product styling into Layout. | +| Freshness audit | Evidence is within its declared advisory or blocking review window. | Continued correctness after source or environment changes. | +| Human review | A named reviewer evaluated the inventory, exclusions, deviations, debt, and claim boundary. | Facts outside the recorded review method and scope. | + +## Page Evidence Matrix + +When page evidence applies, execute widths `320`, `375`, `768`, `1024`, and `1440` pixels. Cover tight, medium, roomy, and full-width containers; empty, short, long, and unbroken content; applicable interactive states and overlays; and declared page-scale changes. Assert logical focus, absence of unusable overflow, sufficient computed contrast, and semantic landmarks in the live page. + +Raster capture is review material. Deterministic computed and semantic assertions are the blocking evidence. Platform-specific screenshots remain nonblocking unless a separately approved platform baseline contract says otherwise. + +## Source And Session Binding + +Create the page session before capture and finalize only from a clean, unchanged source inventory. A completed manifest must preserve the consumer repository and revision, exact relevant-source paths and digests, intended browser scenario IDs, run ID, session ID, runner exit, artifact paths, byte counts, dimensions where relevant, and SHA-256 hashes. Reject cross-revision, cross-run, cross-session, missing, extra, changed, symlinked, escaped, or replayed artifacts. + +For unit and integration evidence, the stored result must be structurally equivalent JSON to the result created by the declared Node `argv` in an isolated checkout of the pinned consumer revision. The governed Node permission boundary denies descendant processes, workers, native addons, and WASI; an `argv` cannot weaken those denials. Whitespace and object-key order may differ; parsed values may not. A prewritten receipt, a zero-exit no-op, current-worktree dependencies, and caller CI identity variables are not runtime proof. + +## Claim Boundary + +A passing record supports: “the named migration scenarios and evidence agree with this declared contract at these pinned revisions.” It cannot support: “the product is fully accessible,” “all behavior is preserved,” “the design is correct,” “StyleGallery certifies this consumer,” or “this local decision is a shared standard.” + +## IA Navigation + +Parent: [Evidence References](index.md). +Next: [Consumer Migration Readiness](../../design-engineering/consumer-migration-readiness.md). diff --git a/quality/evidence/executable-evidence.md b/quality/evidence/executable-evidence.md index c991e9b..9295380 100644 --- a/quality/evidence/executable-evidence.md +++ b/quality/evidence/executable-evidence.md @@ -25,11 +25,19 @@ Executable evidence identifies which claims are machine-enforced, which are only | OKF files have required structural metadata and valid root index or log shapes. | `scripts/validate-okf.mjs` and `scripts/test-validate-okf.mjs` | `node scripts/validate-okf.mjs --json`; `node scripts/test-validate-okf.mjs --json` | `success_path` fixture returns `ok: true`. | Missing frontmatter, missing type, concept frontmatter on `index.md`, and malformed log date fixtures must fail. | Proves repository structure, not conceptual quality or source truth. | | Pattern documents carry the required spatial contract sections and restricted CSS/HTML shape. | `scripts/validate-patterns.mjs` and `scripts/test-validate-patterns.mjs` | `node scripts/validate-patterns.mjs --min-count 46 --json`; `node scripts/test-validate-patterns.mjs --json` | Generated patterns satisfy required fields, sections, CSS ordering, selector, and count rules. | Missing metadata, unsorted CSS, forbidden decorative properties, ID selectors, missing code blocks, missing HTML hooks, and missing contract sections must fail. Preferred accessibility detail labels emit warnings without blocking equivalent prose. | Proves the written pattern contract and static examples, not rendered layout quality. | | Webpage-generation workflow references remain present across guides, recipes, and gates. | `scripts/validate-webpage-workflow.mjs` and `scripts/test-validate-webpage-workflow.mjs` | `node scripts/validate-webpage-workflow.mjs --json`; `node scripts/test-validate-webpage-workflow.mjs --json` | The success fixture includes the required workflow references and contract snippets. | Missing route structure, required template fields, link targets, safety boundaries, or route order must fail. Preferred link labels and GUIDE wording emit warnings. | Proves required structure and safety boundaries are present, not that a generated webpage is visually harmonious. | +| Consumer migration readiness remains discoverable and keeps migration-only handoff semantics. | `scripts/validate-ia.mjs`, `scripts/test-validate-ia.mjs`, `scripts/validate-webpage-workflow.mjs`, and `scripts/test-validate-webpage-workflow.mjs` | Run both validators and fixture harnesses. | Root, domain, consumer-reference, quality gate, evidence, workflow, and taxonomy routes reach the method within two hops; ordinary templates retain reasoned non-applicability while migration guidance declares a separate field. | Missing method routes, missing thirteen-dimension wording, missing migration-only boundary, or replacing the ordinary handoff declaration must fail. | Proves documentation structure and declared handoff semantics, not a consumer migration result. | +| A consumer-owned migration record links thirteen explicit classifications, executable scenarios, adoption mappings, and applicable source-bound page evidence. | `scripts/validate-consumer-conformance.mjs`, `scripts/test-validate-consumer-conformance.mjs`, page-evidence lifecycle scripts, and their fixture harnesses. | Run the conformance and page-evidence contract suites; execute the browser matrix separately when page evidence applies. | Closed schemas, pinned revisions, safe paths, zero-exit scenario records, adoption mappings, and one finalized matching page session pass. | Missing dimensions, unknown scenarios, unsafe paths, source drift, failed runner, cross-run/session/revision reuse, missing/extra/tampered artifacts, and mismatched source or scenario sets fail with named findings. | Supports agreement at pinned revisions only; it does not certify accessibility, product correctness, usability, adoption, or approval. | +| Consumer browser semantics hold across the declared viewport, container, content, state, overlay, and Chromium page-scale matrix. | `tests/consumer-conformance.spec.mjs`, `scripts/test-consumer-conformance-sentinel.mjs`, and `scripts/run-consumer-page-evidence-ci.mjs` | The blocking `consumer-conformance` job runs 147 generated browser scenarios and all eight semantic/filesystem negative controls. The nonblocking `consumer-page-evidence` job creates a source-bound synthetic consumer session, verifies that its receipt uses the synthetic Git repository, branch, and revision instead of the parent Actions identity, captures one selected raster with `PAGE_EVIDENCE_*`, finalizes and validates it, then uploads only the completed packet. | Widths 320, 375, 768, 1024, and 1440; tight, medium, roomy, and full containers; empty, long-label, long-paragraph, CJK, and unbroken content; state, overlay, and Chromium page-scale probes exercise focus, overflow, contrast-ratio math, semantics, and runtime errors. | Overflow, clipped focus, low contrast, focus leakage, console-error mutations, or a leaked parent Actions identity fail on their named assertion; symlinked, pre-existing, and cleanup-redirected page-evidence paths fail without outside writes, deletion, or overwritten bytes. | The blocking job proves deterministic browser contracts in the pinned Chromium environment. The optional validated packet supports review but does not prove usability, accessibility, product suitability, or cross-browser behavior. | +| Explicit evidence deadlines are audited weekly without inventing a repository-wide maximum age. | `scripts/audit-evidence-freshness.mjs`, `scripts/test-audit-evidence-freshness.mjs`, and `scripts/validate-governance.mjs` | `.github/workflows/evidence-freshness.yml` runs Monday at 05:00 UTC and by manual dispatch in advisory mode, emits annotations, and uploads the JSON report for 14 days. | Fresh, expiring, expired, review-due, and no-explicit-expiry records retain their declared semantics. | Malformed records and auditor failures remain blocking even though due-date findings are advisory. | The audit reports only explicit `expires_at` and `review_by` values; it does not infer a TTL, establish evidence quality, or revoke a claim automatically. | | Markdown links resolve inside the repository. | `scripts/validate-links.mjs` | `node scripts/validate-links.mjs --json` | All checked Markdown links resolve to local files or are intentionally external or anchors. | A local link that escapes the repository or points at a missing file must fail. | Proves link targets exist, not that the target content supports a claim. | | Catalog and generated pattern indexes match the pattern data source. | `scripts/validate-catalog.mjs` | `node scripts/validate-catalog.mjs --json` | `CATALOG.md`, category indexes, and expected pattern files match `scripts/pattern-data.mjs`. | Missing, unexpected, or unlisted pattern files must fail. | Proves catalog consistency, not pattern usefulness. | | Governance, lifecycle, generated-file, ownership, and stale-content policy remain discoverable and CI-enforced. | `scripts/validate-governance.mjs` and `scripts/test-validate-governance.mjs` | `node scripts/validate-governance.mjs --json`; `node scripts/test-validate-governance.mjs --json` | `GOVERNANCE.md`, `.github/CODEOWNERS`, generated warnings, generated metadata, root link targets, lifecycle states, `scheduled_stale_audit` metadata, and CI wiring are present. | Missing governance file, generated warning, generated metadata, CODEOWNERS coverage, or stale policy fixtures must fail. Preferred governance prose and link labels emit warnings. | Proves governance policy is present and linked, not that CODEOWNERS users have verified repository write access. | | Domain topology, metadata, provenance, scope boundaries, and root routes remain enforced. | `scripts/validate-domains.mjs` and `scripts/test-validate-domains.mjs` | `node scripts/validate-domains.mjs --json`; `node scripts/test-validate-domains.mjs` | Five governed domains and their declared leaves are reachable and attributed. | Domain metadata, immutable provenance, scope boundaries, and root-route fixtures must fail. | A full SHA proves content identity syntax, not publisher authenticity, source ownership, or local quality. | | Consumer-reference handoffs, schema/runtime parity, repository-local record paths, lifecycle separation, ownership truth, and dependency direction remain enforced. | `scripts/validate-consumer-reference.mjs` and `scripts/test-validate-consumer-reference.mjs` | `node scripts/validate-consumer-reference.mjs --json`; `node scripts/test-validate-consumer-reference.mjs --json` | Declared and reasoned-not-applicable fixtures exit zero, every shipped schema rule has accepted/rejected parity coverage, all repository handoffs declare applicability, fixtures remain related, and ownership discloses placeholder single-account review. | Unknown top-level or nested properties, invalid schema fields, missing handoff or reason, stable with ended support, boolean independence, unresolved/absolute/scheme/network/parent/item-or-record-symlink/non-JSON paths, scaffold success, sixth-domain classification, literal or supported computed reverse imports, and repository handoff omissions must fail with named finding codes and non-zero child exit. | Proves receiver-contract consistency, bounded static dependency detection, and path containment, not general JavaScript data-flow safety, visual quality, independent adoption, verified owner permissions, or consumer implementation conformance. | +| Promotion governance remains a closed JSON-only, invariant-scoped, deferred example contract. | `scripts/validate-promotion-rfc.mjs` and `scripts/test-validate-promotion-rfc.mjs` | `node scripts/validate-promotion-rfc.mjs --json`; `node scripts/test-validate-promotion-rfc.mjs --json` | The manifest-declared policy and two canonical examples pass with deferred decisions, zero attestations, exact support commitment, and no promotion claim. | Manifest-declared invalid fixtures plus forged, unregistered, duplicate, path, extension, support, evidence, canonical-drift, and lifecycle mutations exit non-zero with named findings. | Synthetic validation does not authenticate adoption, organizations, owners, support capacity, provenance, or a promotion decision; the current canonical inventory supplies no real independent consumers. | +| Portable token source and generated CSS agree through the pinned restricted adapter. | `scripts/build-reference-artifacts.mjs`, `scripts/validate-reference-artifacts.mjs`, and `scripts/test-reference-adapters.mjs` | Build with `--fail-on-warning`, validate the generated manifest, then run the adapter fixture harness. | Dimension, color, duration, tested border values, and whole-token curly aliases emit nonzero CSS declarations with matching counts and hashes; output references remain CSS variable references. | `$extends`, JSON Pointer, resolver/modifier/theme documents, unknown reserved fields, non-string descriptions, adapter-unsafe path segments, untested type/unit, partial/dangling/cyclic/type-mismatched aliases, missing output, zero count, warning, scaffold/unknown manifest fields, missing/duplicate declaration, object sentinel, unresolved or forged value, and count drift must fail with named findings and non-zero child exit. | Proves the pinned adapter preserves this tested subset and that committed artifacts independently rebuild from their canonical source, not that tokens are product defaults, portable beyond the tested adapters, visually suitable, or adopted by a consumer. | +| The proposed Chromium sentinel preserves canonical card-grid geometry and truth-derived calibration evidence. | `tests/consumer-reference-sentinels.spec.mjs`, `scripts/test-consumer-reference-sentinel.mjs`, `scripts/validate-baseline-manifest.mjs`, and strict raw calibration harnesses. | Run the ordinary sentinel and semantic negatives in the digest-pinned nonblocking Chromium job; validate manifest/raw evidence in the contract job. | Computed visibility/cardinality/grid/gap/geometry/overflow checks precede a locator screenshot; raw DOM and AX bytes are recorded and hash-checked across runs; completion and zero diff derive only from a zero child exit, the exact passing Playwright identity, verified raw hashes, and post-assertion comparison proof. | Hidden layout, long-content overflow, fake/malformed Playwright, missing or nonzero exit, absent comparison/artifact, tampered hash, duplicate/unknown metadata, malformed recursive records, and incomplete run sets must fail without writing a completed aggregate. | Local DOM/AX/rendered evidence does not prove semantic DOM/AX conformance, product suitability, accessibility, or owner approval. Completed-CI repository, workflow, run ID and attempt, SHA, and artifact-name fields are workflow-recorded, self-asserted metadata, not an external attestation. The self-asserted repository field names the canonical upstream changeroa/StyleGallery; the self-asserted execution_repository field names the actual GitHub Actions repository and is limited to changeroa/StyleGallery or ark-jo/StyleGallery. The committed calibration's external_verification object records an independently checked GitHub Actions run and artifact API identity; artifact.api_digest is distinct from committed_ci.raw_evidence_sha256. Future CI aggregates remain awaiting_external_verification until their uploaded artifact API identity is independently checked. Linux/amd64 repeatability is externally verified only for committed run 29260372260; it does not establish baseline-owner approval or product suitability. Baseline-owner approval remains unclaimed until the named owner explicitly approves it. Synthetic fixtures validate rejection and acceptance behavior only; they are not authenticated provenance. Both Chromium jobs are nonblocking and CI never updates snapshots. | +| Governed-local button states retain source-bound visual, DOM, and accessibility-tree evidence across both example profiles. | `scripts/create-component-state-session.mjs`, `scripts/finalize-component-state-evidence.mjs`, `scripts/validate-component-state.mjs`, and the component-state source, contract, artifact, generator, and sentinel harnesses. | `npm run test:component-state`; `npm run validate:component-state`; run the digest-pinned component-state browser capture and `npm run test:component-state:runtime-negative`. | Editorial and terminal each declare the five compound scenarios `action-disabled-busy`, `action-focused`, `action-loading-busy`, `disclosure-expanded-loading`, and `toggle-focused-pressed`; their environment-scoped approved PNG hashes and dimensions are canonical pre-capture inputs; the generated state, keyboard, and evidence matrices match their records; one clean capture session binds the browser artifacts to the governed source inventory. | Missing or altered source bindings, missing executable source manifests, invalid state/profile records, scenario drift, coordinated PNG/manifest/sidecar substitution, unapproved environment-specific raster identity, malformed or replayed runtime artifacts, hash/count mismatch, insufficient focus clearance, and sentinel mutations must exit non-zero with named findings. | Proves the declared examples and captured channels agree for an explicitly approved browser environment. Legacy source-less receipts are intentionally rejected by the capture receipt, `session_link`, and `completed_session` schemas and by executable finalization and validation. Environment-scoped PNG identity does not imply cross-platform pixel equivalence. It does not prove product suitability, independent adoption, full accessibility, cross-browser behavior, or owner approval; synthetic negative fixtures are not authenticated provenance. | ## Validator Severity Contract diff --git a/quality/evidence/index.md b/quality/evidence/index.md index c49f0fa..7eaf10e 100644 --- a/quality/evidence/index.md +++ b/quality/evidence/index.md @@ -7,6 +7,7 @@ Evidence references describe what sources and artifacts can support. They do not - [Sources](sources.md) - Source admissibility and source families. - [Evidence family glossary](families.md) - Normalized `validator`, `screenshot`, `accessibility`, `user`, `source`, and `rationale` families for claim records. - [Executable evidence](executable-evidence.md) - Validator, fixture, CI, rendered, review, and citation boundaries. +- [Consumer migration evidence](consumer-migration.md) - Runtime, page-session, adoption, freshness, and human-review boundaries for migration readiness. - [Visual QA](visual-qa.md) - Rendered evidence references and limits. - [Accessibility](accessibility.md) - Accessibility evidence references and limits. - [HCI evaluation](hci-evaluation.md) - Heuristic, walkthrough, and user-evidence references. diff --git a/quality/gates/consumer-migration-evidence.md b/quality/gates/consumer-migration-evidence.md new file mode 100644 index 0000000..780ec2e --- /dev/null +++ b/quality/gates/consumer-migration-evidence.md @@ -0,0 +1,53 @@ +--- +type: Quality Gate +title: Consumer Migration Evidence Gate +description: Blocking contract for consumer-owned migration conformance and page evidence. +--- + +# Consumer Migration Evidence Gate + +Use this gate only for a declared consumer migration. Ordinary implementation handoffs keep the existing consumer-reference applicability contract and do not need a migration record. + +## Required Contract + +Record: + +```txt +Consumer migration conformance: declared +Consumer migration conformance record: path/to/consumer-conformance.json +Consumer revision: +Relevant source inventory: +Thirteen migration-dimension classifications: +Runtime scenario results: +Adoption mappings: +Page evidence: applicable | not_applicable +Deviations: +Debt and review dates: +Claim boundary: +Decision: pass | block +``` + +The record path is normalized, repository-relative JSON inside the consuming repository. The record follows the [consumer conformance schema](../../consumer-reference/schema/consumer-conformance-record.schema.json) and the [receiver contract](../../consumer-reference/contract.md). + +## Blocking Conditions + +Block when any of the following is true: + +- any of the thirteen migration dimensions is absent rather than explicitly `applicable` or `not_applicable`; +- an applicable dimension has no declared runtime scenario, or a scenario has a nonzero exit, missing result artifact, mismatched run/session identity, or stale source digest; +- an adoption mapping lacks a pinned StyleGallery revision and anchor, concrete local target, local decision, scenario link, or declared deviations and debt; +- page evidence is applicable but its source inventory, revision, browser scenario catalog, repository, run, session, artifact hashes, or finalized manifest does not match the conformance record; +- a source changes between session creation and finalization, an artifact escapes the evidence root, a symlink or unmanifested artifact is accepted, or evidence is reused across sessions; +- expired blocking evidence or malformed freshness metadata is presented as current; +- the handoff implies that passing validator or browser checks proves complete accessibility, product correctness, usability, or approval. + +## Evidence Decision + +Pass means only that the declared migration contract and its named evidence agree at the pinned consumer and StyleGallery revisions. It does not mean the migration is universally correct or that omitted behavior is safe. Reviewers still decide whether the inventory and `not_applicable` reasons are credible. + +Use [Consumer Migration Evidence](../evidence/consumer-migration.md) to interpret runtime, browser, freshness, and review channels. Use [Executable Evidence](../evidence/executable-evidence.md) for the repository-wide distinction between structural enforcement and human judgment. + +## IA Navigation + +Parent: [Gate Contracts](index.md). +Next: [Consumer Migration Evidence](../evidence/consumer-migration.md). diff --git a/quality/gates/harmony-evaluation.md b/quality/gates/harmony-evaluation.md index 509f0e5..a87413f 100644 --- a/quality/gates/harmony-evaluation.md +++ b/quality/gates/harmony-evaluation.md @@ -70,6 +70,8 @@ This gate decides whether a webpage composition is coherent enough to guide impl Use a [structured claim record](../claims.md) when the harmony decision approves, blocks, or redirects implementation. Harmony records must include the use case and implementation handoff because they decide what the implementer may safely carry from a visual reference into product-level styling. +If the work is an existing-consumer migration that declares conformance, route the separate migration evidence decision through the [Consumer Migration Evidence Gate](consumer-migration-evidence.md). Harmony cannot substitute for runtime parity or source-bound page evidence. + ## IA Navigation Parent: [Gate Contracts](index.md). diff --git a/quality/gates/index.md b/quality/gates/index.md index 1bc0373..f6d9459 100644 --- a/quality/gates/index.md +++ b/quality/gates/index.md @@ -10,3 +10,4 @@ Gate contracts define what kind of evidence can support a claim. - [Accessibility evidence gate](accessibility-evidence.md) - Accessibility evidence and review boundaries. - [Rationale gate](rationale.md) - Decision rationale, alternatives, warrants, and debt. - [Harmony evaluation gate](harmony-evaluation.md) - Contract for judging content-to-layout fit, overall harmony, GPT Image references, and implementation handoff. +- [Consumer migration evidence gate](consumer-migration-evidence.md) - Blocking contract for consumer-owned migration conformance, runtime proof, and source-bound page evidence. diff --git a/quality/index.md b/quality/index.md index 9ec2b3a..f477c05 100644 --- a/quality/index.md +++ b/quality/index.md @@ -16,6 +16,8 @@ The [Consumer Reference Receiver Contract](../consumer-reference/contract.md) is - [Claim records](claim-records/index.md) - Filled examples for high-impact claims. - [Accessibility evidence register](evidence/accessibility.md) - Classification for accessibility claims: automated, manual, user, or debt. - [Executable evidence coverage](evidence/executable-evidence.md) - Validator, fixture, CI, rendered-evidence, and review boundaries. +- [Consumer migration evidence gate](gates/consumer-migration-evidence.md) - Blocking contract for a declared consumer migration. +- [Consumer migration evidence](evidence/consumer-migration.md) - Claim boundaries for runtime and source-bound page evidence. ## Admission Model @@ -47,6 +49,7 @@ Use this script for lightweight tree tests: | Review product-level interface craft. | [README](../README.md) | [Interface Craft Decisions](../design-engineering/interface-craft.md) | The first selected route is Design Engineering and shared quality gates remain visible. | | Compare an Apple interaction convention. | [README](../README.md) | [Apple Interaction](../platform-guides/apple-interaction.md) | The first selected route is Platform Guides and source/version limits are reached within two hops. | | Declare whether a consumer reference applies. | [README](../README.md) | [Consumer Reference](../consumer-reference/index.md) | The handoff reaches the shared contract without classifying it as a sixth domain. | +| Prove a consumer migration preserves declared behavior. | [README](../README.md) | [Consumer Migration Readiness](../design-engineering/consumer-migration-readiness.md) | The reader reaches the Design Engineering method and its evidence gate within two hops. | | Find the authority route for uGUI, UI Toolkit, or NGUI. | [README](../README.md) | [Unity UI Systems](../game-ui/unity/ui-systems.md) | The first selected route is Game UI, and the system-specific source and version boundary is reached within three hops. | Record `PASS` only when the expected primary route is the first route selected. A link resolving successfully is not enough; the selected route must match the task intent. diff --git a/scripts/artifact-metadata.mjs b/scripts/artifact-metadata.mjs new file mode 100644 index 0000000..2e5cab6 --- /dev/null +++ b/scripts/artifact-metadata.mjs @@ -0,0 +1,20 @@ +import crypto from "node:crypto"; + +const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +function pngDimensions(bytes) { + if (bytes.length < 24 || !bytes.subarray(0, 8).equals(pngSignature) || bytes.toString("ascii", 12, 16) !== "IHDR") return undefined; + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + return width > 0 && height > 0 ? { height, width } : undefined; +} + +export function artifactMetadata(bytes, mediaType) { + const metadata = { + byte_length: bytes.length, + media_type: mediaType, + sha256: `sha256:${crypto.createHash("sha256").update(bytes).digest("hex")}`, + }; + if (mediaType === "image/png") Object.assign(metadata, pngDimensions(bytes)); + return metadata; +} diff --git a/scripts/audit-evidence-freshness.mjs b/scripts/audit-evidence-freshness.mjs new file mode 100644 index 0000000..a33f195 --- /dev/null +++ b/scripts/audit-evidence-freshness.mjs @@ -0,0 +1,308 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; +import { addDateTimeFormat, isRfc3339DateTime } from "./json-schema-formats.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; +import { validateCalibration } from "./baseline-contract.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const calibrationSchemaPath = path.join(repositoryRoot, "consumer-reference", "schema", "calibration-record.schema.json"); +const defaultPageSchemaPath = path.join(repositoryRoot, "consumer-reference", "schema", "page-evidence-manifest.schema.json"); +const PAGE_RECORD_KIND = "page_evidence_manifest"; +// This is an advisory warning window only. It never changes an explicit expiry or adds a TTL. +const DEFAULT_EXPIRING_WINDOW_DAYS = 7; + +function failure(code, sourcePath, message, details = undefined) { + return details === undefined ? { code, message, path: sourcePath } : { code, message, path: sourcePath, details }; +} + +function warning(code, sourcePath, status, thresholdDate, asOf, message) { + return { asOf, code, message, source_path: sourcePath, status, threshold_date: thresholdDate }; +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function parseDateTime(value) { + return isRfc3339DateTime(value) ? Date.parse(value) : NaN; +} + +function isInside(base, target) { + const relative = path.relative(path.resolve(base), path.resolve(target)); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +function displayPath(file, root) { + const absolute = path.resolve(file); + if (isInside(root, absolute)) return path.relative(root, absolute).split(path.sep).join("/") || "."; + return absolute; +} + +function readRecord(file, root, failures) { + const sourcePath = displayPath(file, root); + let stat; + try { + stat = fs.lstatSync(file); + } catch (error) { + failures.push(failure("freshness_record_unreadable", sourcePath, `record cannot be read: ${error instanceof Error ? error.message : String(error)}`)); + return undefined; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + failures.push(failure("freshness_record_unreadable", sourcePath, "record must be a regular non-symlink file")); + return undefined; + } + if (stat.size > 1024 * 1024) { + failures.push(failure("freshness_record_unreadable", sourcePath, "record must not exceed 1 MiB")); + return undefined; + } + try { + return { sourcePath, value: parseStrictJson(fs.readFileSync(file, "utf8")) }; + } catch (error) { + failures.push(failure("freshness_record_invalid", sourcePath, error instanceof Error ? error.message : String(error))); + return undefined; + } +} + +function readSchema(schemaPath, sourcePath, failures) { + let stat; + try { + stat = fs.lstatSync(schemaPath); + } catch (error) { + failures.push(failure("freshness_schema_missing", sourcePath, `schema cannot be read: ${error instanceof Error ? error.message : String(error)}`)); + return undefined; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + failures.push(failure("freshness_schema_invalid", sourcePath, "schema must be a regular non-symlink file")); + return undefined; + } + try { + return parseStrictJson(fs.readFileSync(schemaPath, "utf8")); + } catch (error) { + failures.push(failure("freshness_schema_invalid", sourcePath, error instanceof Error ? error.message : String(error))); + return undefined; + } +} + +function compileSchema(schema, schemaPath, sourcePath, failures, { strict = true } = {}) { + if (!isRecord(schema)) { + failures.push(failure("freshness_schema_invalid", sourcePath, "schema must be a JSON object")); + return undefined; + } + try { + const ajv = new Ajv2020({ allErrors: true, strict }); + addDateTimeFormat(ajv); + const schemaDirectory = path.dirname(defaultPageSchemaPath); + if (path.resolve(schemaPath) === path.resolve(defaultPageSchemaPath) && fs.existsSync(schemaDirectory)) { + for (const entry of fs.readdirSync(schemaDirectory, { withFileTypes: true }).filter((item) => item.isFile() && item.name.endsWith(".schema.json"))) { + const dependencyPath = path.join(schemaDirectory, entry.name); + if (path.resolve(dependencyPath) === path.resolve(schemaPath)) continue; + try { + const dependency = parseStrictJson(fs.readFileSync(dependencyPath, "utf8")); + if (dependency?.$id) ajv.addSchema(dependency, dependency.$id); + } catch { + // Only schemas reachable from the selected page schema affect this compile. + } + } + } + if (schema.$id) ajv.addSchema(schema, schema.$id); + else ajv.addSchema(schema, schemaPath); + return ajv.getSchema(schema.$id ?? schemaPath) ?? ajv.compile(schema); + } catch (error) { + failures.push(failure("freshness_schema_invalid", sourcePath, error instanceof Error ? error.message : String(error))); + return undefined; + } +} + +function schemaFailures(validate, value, sourcePath) { + if (validate(value)) return []; + return (validate.errors ?? []).map((error) => failure( + "freshness_record_invalid", + sourcePath, + `${error.instancePath || "/"} ${error.message}`, + )); +} + +function validateCalibrationRecord(value, sourcePath, failures) { + const schema = readSchema(calibrationSchemaPath, sourcePath, failures); + const validate = schema === undefined ? undefined : compileSchema(schema, calibrationSchemaPath, sourcePath, failures, { strict: false }); + if (validate) failures.push(...schemaFailures(validate, value, sourcePath)); + for (const finding of validateCalibration(value, sourcePath)) { + failures.push(failure("freshness_record_invalid", sourcePath, finding.message, { code: finding.code })); + } + if (!isRecord(value)) return undefined; + const external = value.committed_ci?.external_verification; + if (external === null || external === undefined) return { thresholdDate: null, verifiedAt: null }; + if (!isRecord(external) || !isRecord(external.artifact)) { + failures.push(failure("freshness_record_invalid", sourcePath, "calibration external verification must contain artifact metadata")); + return undefined; + } + const thresholdDate = external.artifact.expires_at; + const verifiedAt = external.verified_at; + if (!Number.isFinite(parseDateTime(thresholdDate)) || !Number.isFinite(parseDateTime(verifiedAt))) { + failures.push(failure("freshness_record_invalid", sourcePath, "calibration expiry and verification dates must be RFC 3339 date-time values")); + return undefined; + } + return { thresholdDate, verifiedAt }; +} + +function validatePageRecord(value, sourcePath, pageSchemaPath, failures) { + const schema = readSchema(pageSchemaPath, sourcePath, failures); + if (schema === undefined) return undefined; + const validate = compileSchema(schema, pageSchemaPath, sourcePath, failures, { strict: true }); + if (validate) failures.push(...schemaFailures(validate, value, sourcePath)); + if (!isRecord(value) || value.record_kind !== PAGE_RECORD_KIND) return undefined; + const thresholdDate = value.review_by; + if (!Number.isFinite(parseDateTime(thresholdDate))) { + failures.push(failure("freshness_record_invalid", sourcePath, "page evidence review_by must be an RFC 3339 date-time value")); + return undefined; + } + return { thresholdDate, verifiedAt: null }; +} + +function classify(entry, asOfMs, windowMs) { + if (entry.thresholdDate === null) return "fresh"; + const thresholdMs = Date.parse(entry.thresholdDate); + if (entry.kind === PAGE_RECORD_KIND) { + if (asOfMs >= thresholdMs) return "review_due"; + return thresholdMs - asOfMs <= windowMs ? "expiring" : "fresh"; + } + if (asOfMs >= thresholdMs) return "expired"; + return thresholdMs - asOfMs <= windowMs ? "expiring" : "fresh"; +} + +function isBlockingStatus(status) { + return status === "expired" || status === "review_due"; +} + +function auditOne({ file, asOf, asOfMs, mode, root, pageSchemaPath, expiringWindowMs }) { + const failures = []; + const sourcePath = displayPath(file, root); + const loaded = readRecord(file, root, failures); + if (!loaded) return { failures, warnings: [], record: null }; + const { value } = loaded; + if (!isRecord(value)) { + failures.push(failure("freshness_record_invalid", sourcePath, "record must be a JSON object")); + return { failures, warnings: [], record: null }; + } + let kind; + let dates; + if (value.record_kind === PAGE_RECORD_KIND) { + kind = PAGE_RECORD_KIND; + dates = validatePageRecord(value, sourcePath, pageSchemaPath, failures); + } else if (value.committed_ci !== undefined || value.required_runs !== undefined || value.runs !== undefined) { + kind = "calibration_record"; + dates = validateCalibrationRecord(value, sourcePath, failures); + } else { + failures.push(failure("freshness_record_invalid", sourcePath, "record_kind is not a supported evidence record")); + return { failures, warnings: [], record: null }; + } + if (dates === undefined || failures.length > 0) return { failures, warnings: [], record: null }; + const entry = { asOf, kind, source_path: sourcePath, threshold_date: dates.thresholdDate, verified_at: dates.verifiedAt }; + const status = classify({ kind, thresholdDate: dates.thresholdDate }, asOfMs, expiringWindowMs); + const result = { ...entry, status }; + const warnings = []; + if (status !== "fresh") { + const code = status === "expiring" ? "evidence_expiring" : status === "expired" ? "evidence_expired" : "evidence_review_due"; + const message = status === "expiring" + ? `evidence expiry is within the advisory window (${dates.thresholdDate})` + : status === "expired" + ? `evidence expired at ${dates.thresholdDate}` + : `evidence review is due at ${dates.thresholdDate}`; + warnings.push(warning(code, sourcePath, status, dates.thresholdDate, asOf, message)); + if (mode === "blocking" && isBlockingStatus(status)) failures.push(failure(code, sourcePath, message, { asOf, threshold_date: dates.thresholdDate })); + } + return { failures, record: result, warnings }; +} + +export function parseArguments(argv = process.argv.slice(2)) { + const options = { asOf: null, expiringWindowDays: DEFAULT_EXPIRING_WINDOW_DAYS, json: false, mode: "advisory", pageSchema: defaultPageSchemaPath, records: [] }; + const failures = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--json") { + options.json = true; + continue; + } + if (["--record", "--as-of", "--mode", "--page-schema", "--expiring-within-days"].includes(argument)) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + failures.push(failure("argument_value_required", "", `${argument} requires a value`)); + continue; + } + index += 1; + if (argument === "--record") options.records.push(value); + else if (argument === "--as-of") options.asOf = value; + else if (argument === "--mode") options.mode = value; + else if (argument === "--page-schema") options.pageSchema = path.resolve(process.cwd(), value); + else options.expiringWindowDays = Number(value); + continue; + } + failures.push(failure("argument_unknown", "", `unsupported argument ${argument}`)); + } + if (options.records.length === 0) failures.push(failure("record_required", "", "at least one --record is required")); + if (options.asOf === null) failures.push(failure("as_of_required", "", "--as-of is required for deterministic freshness auditing")); + else if (!Number.isFinite(parseDateTime(options.asOf))) failures.push(failure("as_of_invalid", "", "--as-of must be an RFC 3339 date-time value")); + if (!["advisory", "blocking"].includes(options.mode)) failures.push(failure("mode_invalid", "", "--mode must be advisory or blocking")); + if (!Number.isFinite(options.expiringWindowDays) || options.expiringWindowDays < 0 || options.expiringWindowDays > 3650) failures.push(failure("expiring_window_invalid", "", "--expiring-within-days must be a number from 0 through 3650")); + return { failures, options }; +} + +export function auditEvidenceFreshness({ records, asOf, mode = "advisory", root = repositoryRoot, pageSchema = defaultPageSchemaPath, expiringWindowDays = DEFAULT_EXPIRING_WINDOW_DAYS }) { + const asOfMs = parseDateTime(asOf); + const failures = []; + const warnings = []; + const entries = []; + if (!Number.isFinite(asOfMs)) failures.push(failure("as_of_invalid", "", "asOf must be an RFC 3339 date-time value")); + for (const record of records) { + const file = path.resolve(root, record); + const result = auditOne({ asOf, asOfMs, expiringWindowMs: expiringWindowDays * 24 * 60 * 60 * 1000, file, mode, pageSchemaPath: pageSchema, root }); + failures.push(...result.failures); + warnings.push(...result.warnings); + if (result.record) entries.push(result.record); + } + const uniqueFailures = [...new Map(failures.map((item) => [`${item.code}:${item.path}:${item.message}`, item])).values()]; + const uniqueWarnings = [...new Map(warnings.map((item) => [`${item.code}:${item.source_path}:${item.threshold_date}`, item])).values()]; + return { + asOf, + expiring_window_days: expiringWindowDays, + failures: uniqueFailures, + mode, + ok: uniqueFailures.length === 0, + records: entries, + warnings: uniqueWarnings, + }; +} + +export function runCli(argv = process.argv.slice(2), root = repositoryRoot) { + const parsed = parseArguments(argv); + if (parsed.failures.length > 0) return { asOf: parsed.options.asOf, failures: parsed.failures, mode: parsed.options.mode, ok: false, records: [], warnings: [] }; + return auditEvidenceFreshness({ + asOf: parsed.options.asOf, + expiringWindowDays: parsed.options.expiringWindowDays, + mode: parsed.options.mode, + pageSchema: parsed.options.pageSchema, + records: parsed.options.records, + root, + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const parsed = parseArguments(); + const report = parsed.failures.length > 0 + ? { asOf: parsed.options.asOf, failures: parsed.failures, mode: parsed.options.mode, ok: false, records: [], warnings: [] } + : runCli(); + if (parsed.options.json || parsed.failures.length > 0) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + else if (report.ok) process.stdout.write(`ok: audited ${report.records.length} evidence record(s)\n`); + else process.stderr.write(`${report.failures.map((item) => `${item.code}: ${item.message}`).join("\n")}\n`); + process.exitCode = report.ok ? 0 : 1; + } catch (error) { + const report = { failures: [failure("freshness_tool_error", "", error instanceof Error ? error.message : String(error))], ok: false, records: [], warnings: [] }; + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/baseline-contract.mjs b/scripts/baseline-contract.mjs new file mode 100644 index 0000000..1baab46 --- /dev/null +++ b/scripts/baseline-contract.mjs @@ -0,0 +1,189 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { parseStrictJson } from "./strict-json.mjs"; + +export const BASELINE_ENVIRONMENT = { + architecture: "amd64", + browser_revision: "1228", + browser_version: "149.0.7827.55", + container_image: "mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a", + node: "22", + os: "Ubuntu 24.04", + platform: "linux/amd64", + playwright: "1.61.0", + viewport: { height: 768, width: 1024 }, +}; + +export const BASELINE_REFERENCE = { + baseline: { path: "tests/snapshots/consumer-reference-card-grid.png", sha256: "5528358e957a6115793155e501f62716f7db31dc1c86856d9e1234868d672837" }, + source: { path: "tests/helpers/render-consumer-reference.mjs", sha256: "88802a948909d5e40470be6b5481766ce2de498e59c053ac68af370b46e72ca9" }, +}; + +export const BASELINE_METADATA_SHA256 = sha256(JSON.stringify({ environment: BASELINE_ENVIRONMENT, reference: BASELINE_REFERENCE })); +export const CALIBRATION_CANONICAL_REPOSITORY = "changeroa/StyleGallery"; +export const CALIBRATION_EXECUTION_REPOSITORIES = Object.freeze(["ark-jo/StyleGallery", CALIBRATION_CANONICAL_REPOSITORY]); +export const CALIBRATION_EXTERNAL_VERIFICATION = { + artifact: { + api_digest: "sha256:3f11a517b447e1b5a1da17d9ee66ba2dd947fc8a0a482c447556ef00652e6074", + expires_at: "2026-07-27T15:01:36Z", + id: "8283099324", + name: "chromium-sentinel-calibration-29260372260-18229be570766d3b42f5600955120bfcba690b76", + size_in_bytes: 70310, + }, + repository_relationship: { + execution_is_fork: true, + parent: CALIBRATION_CANONICAL_REPOSITORY, + source: CALIBRATION_CANONICAL_REPOSITORY, + }, + source: "https://github.com/ark-jo/StyleGallery/actions/runs/29260372260", + verified_at: "2026-07-13T15:04:41Z", +}; + +export function finding(code, file, message) { + return { code, message, path: file }; +} + +export function sha256(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +export function readJson(file, failures) { + if (!fs.existsSync(file)) { + failures.push(finding("baseline_file_missing", file, "required baseline record is missing")); + return undefined; + } + try { + const stat = fs.lstatSync(file); + if (stat.isSymbolicLink() || !stat.isFile() || stat.size > 1024 * 1024) throw new Error("JSON input must be a regular file no larger than 1 MiB"); + return parseStrictJson(fs.readFileSync(file, "utf8")); + } catch (error) { + failures.push(finding("baseline_json_invalid", file, error instanceof Error ? error.message : String(error))); + return undefined; + } +} + +function sameValue(left, right) { + return isDeepStrictEqual(left, right); +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function rejectUnknown(value, allowed, code, file, failures) { + if (!isRecord(value)) return; + for (const key of Object.keys(value).filter((key) => !allowed.has(key))) { + failures.push(finding(code, file, `unsupported property ${key}`)); + } +} + +function isHash(value) { + return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); +} + +export function validateEnvironment(environment, file) { + return sameValue(environment, BASELINE_ENVIRONMENT) + ? [] + : [finding("calibration_environment_mismatch", file, "environment must match the immutable linux/amd64 calibration image")]; +} + +export function validateCalibration(record, file) { + const failures = []; + if (!isRecord(record)) return [finding("calibration_record_invalid", file, "calibration record must be an object")]; + const allowed = new Set(["baseline_owner_approval", "committed_ci", "environment", "reference", "required_runs", "runs", "schema_version", "status"]); + rejectUnknown(record, allowed, "calibration_property_unknown", file, failures); + if (record.schema_version !== "1.0") failures.push(finding("calibration_schema_version", file, "schema_version must be 1.0")); + if (record.baseline_owner_approval !== "pending") failures.push(finding("baseline_owner_approval_invalid", file, "baseline owner approval must remain pending")); + if (record.required_runs !== 20) failures.push(finding("calibration_required_runs", file, "required_runs must equal 20")); + failures.push(...validateEnvironment(record.environment, file)); + if (!sameValue(record.reference, BASELINE_REFERENCE)) failures.push(finding("calibration_reference_mismatch", file, "source and baseline hashes must match the proposed reference")); + if (!Array.isArray(record.runs)) return [...failures, finding("calibration_runs_invalid", file, "runs must be an array")]; + if (record.status === "awaiting_committed_ci") { + if (record.runs.length !== 0 || record.committed_ci !== null) failures.push(finding("calibration_pending_evidence_forbidden", file, "pending calibration cannot claim runs or committed CI")); + return failures; + } + if (!["awaiting_external_verification", "completed"].includes(record.status)) return [...failures, finding("calibration_status_invalid", file, "status must be awaiting_committed_ci, awaiting_external_verification, or completed")]; + if (!isRecord(record.committed_ci)) failures.push(finding("calibration_committed_ci_missing", file, "calibrated record requires committed CI metadata")); + else { + rejectUnknown(record.committed_ci, new Set(["artifact_name", "checkout_sha", "execution_repository", "external_verification", "head_sha", "raw_evidence_sha256", "repository", "run_attempt", "run_id", "sha", "workflow"]), "calibration_committed_ci_property_unknown", file, failures); + const expectedArtifact = `chromium-sentinel-calibration-${record.committed_ci.run_id}-${record.committed_ci.sha}`; + if (!/^[0-9]+$/.test(record.committed_ci.run_id ?? "") + || !/^[0-9]+$/.test(record.committed_ci.run_attempt ?? "") + || !/^[0-9a-f]{40}$/.test(record.committed_ci.sha ?? "") + || !/^[0-9a-f]{40}$/.test(record.committed_ci.head_sha ?? "") + || record.committed_ci.checkout_sha !== record.committed_ci.sha + || !CALIBRATION_EXECUTION_REPOSITORIES.includes(record.committed_ci.execution_repository) + || !/^[0-9a-f]{64}$/.test(record.committed_ci.raw_evidence_sha256 ?? "") + || record.committed_ci.repository !== CALIBRATION_CANONICAL_REPOSITORY + || record.committed_ci.workflow !== ".github/workflows/validate.yml" + || record.committed_ci.artifact_name !== expectedArtifact) { + failures.push(finding("calibration_committed_ci_invalid", file, "canonical and execution repositories, workflow, run, attempt, SHAs, raw evidence, and artifact name must have the canonical identity")); + } + if (record.status === "awaiting_external_verification" && record.committed_ci.external_verification !== null) { + failures.push(finding("calibration_external_verification_preclaim", file, "awaiting calibration must not claim uploaded artifact verification")); + } + if (record.status === "completed") { + if (!("external_verification" in record.committed_ci) || record.committed_ci.external_verification === null) { + failures.push(finding("calibration_external_verification_missing", file, "completed calibration requires independently checked run and artifact metadata")); + } else { + const external = record.committed_ci.external_verification; + const expectedSource = `https://github.com/${record.committed_ci.execution_repository}/actions/runs/${record.committed_ci.run_id}`; + if (!sameValue(external, CALIBRATION_EXTERNAL_VERIFICATION) + || external?.source !== expectedSource + || external?.artifact?.name !== record.committed_ci.artifact_name + || external?.artifact?.api_digest === `sha256:${record.committed_ci.raw_evidence_sha256}`) { + failures.push(finding("calibration_external_verification_invalid", file, "external verification must equal the independently checked GitHub Actions run, fork relationship, and artifact API identity")); + } + } + } + } + if (record.runs.length !== 20) failures.push(finding("calibration_run_count", file, "calibrated record requires exactly 20 runs")); + const runAllowed = new Set(["architecture", "ax_sha256", "completed", "dom_sha256", "metadata_sha256", "png_sha256", "run", "screenshot_diff_pixels"]); + for (const run of record.runs) { + if (!isRecord(run)) { + failures.push(finding("calibration_run_invalid", file, "every run must be an object")); + continue; + } + rejectUnknown(run, runAllowed, "calibration_run_property_unknown", file, failures); + if (!Number.isInteger(run.run) || run.run < 1 || run.run > 20) failures.push(finding("calibration_run_number_invalid", file, "run must be an integer from 1 through 20")); + if (run.completed !== true) failures.push(finding("calibration_run_incomplete", file, "every calibration run must complete")); + if (run.architecture !== "amd64") failures.push(finding("calibration_architecture_variance", file, "every run must record architecture amd64")); + if (![run.ax_sha256, run.dom_sha256, run.metadata_sha256, run.png_sha256].every(isHash)) failures.push(finding("calibration_hash_invalid", file, "every artifact hash must be 64 lowercase hexadecimal characters")); + if (run.png_sha256 !== BASELINE_REFERENCE.baseline.sha256) failures.push(finding("calibration_baseline_hash_mismatch", file, "every completed PNG must equal the proposed baseline hash")); + if (run.metadata_sha256 !== BASELINE_METADATA_SHA256) failures.push(finding("calibration_metadata_hash_mismatch", file, "every completed metadata hash must equal the canonical environment and reference hash")); + if (run.screenshot_diff_pixels !== 0) failures.push(finding("calibration_diff_nonzero", file, "every screenshot diff must equal zero")); + } + const runNumbers = record.runs.map((run) => run?.run); + if (new Set(runNumbers).size !== runNumbers.length) failures.push(finding("calibration_run_duplicate", file, "calibration run numbers must be unique")); + if (!sameValue([...runNumbers].sort((left, right) => left - right), Array.from({ length: 20 }, (_, index) => index + 1))) failures.push(finding("calibration_run_set", file, "calibration runs must be the complete range 1 through 20")); + const architectures = new Set(record.runs.map((run) => run?.architecture)); + if (architectures.size !== 1 || !architectures.has("amd64")) failures.push(finding("calibration_architecture_variance", file, "every run must record architecture amd64")); + if (["png_sha256", "dom_sha256", "ax_sha256"].some((field) => new Set(record.runs.map((run) => run?.[field])).size !== 1)) failures.push(finding("calibration_hash_variance", file, "PNG, DOM, and AX hashes must each be identical across runs")); + if (new Set(record.runs.map((run) => run?.metadata_sha256)).size !== 1) failures.push(finding("calibration_metadata_variance", file, "stable environment metadata must be identical across runs")); + return failures; +} + +export function validateManifest(manifest, file, root) { + const failures = []; + if (!isRecord(manifest)) return [finding("baseline_manifest_invalid", file, "baseline manifest must be an object")]; + const allowed = new Set(["architecture", "baseline", "baseline_owner_approval", "browser_revision", "browser_version", "calibration", "container_image", "node", "os", "platform", "playwright", "schema_version", "source", "status", "viewport"]); + rejectUnknown(manifest, allowed, "baseline_manifest_property_unknown", file, failures); + if (manifest.schema_version !== "1.0" || manifest.status !== "proposed") failures.push(finding("baseline_manifest_state", file, "manifest must be proposed schema 1.0")); + if (manifest.baseline_owner_approval !== "pending") failures.push(finding("baseline_owner_approval_invalid", file, "baseline owner approval must remain pending")); + for (const [key, value] of Object.entries(BASELINE_ENVIRONMENT)) if (!sameValue(manifest[key], value)) failures.push(finding("baseline_environment_mismatch", file, `${key} differs from the immutable environment`)); + rejectUnknown(manifest.calibration, new Set(["record", "required_runs", "workflow_artifact_name"]), "baseline_calibration_property_unknown", file, failures); + if (!isRecord(manifest.calibration) || manifest.calibration.required_runs !== 20 || manifest.calibration.record !== "consumer-reference/baselines/calibration.json") failures.push(finding("baseline_calibration_route", file, "manifest must route to the 20-run calibration record")); + if (manifest.calibration?.workflow_artifact_name !== "chromium-sentinel-calibration-${run_id}-${sha}") failures.push(finding("baseline_artifact_name", file, "workflow artifact name template is immutable")); + for (const [kind, entry] of [["source", manifest.source], ["baseline", manifest.baseline]]) { + rejectUnknown(entry, new Set(["path", "sha256"]), `baseline_${kind}_property_unknown`, file, failures); + if (!sameValue(entry, BASELINE_REFERENCE[kind])) failures.push(finding(`baseline_${kind}_reference_mismatch`, file, `${kind} identity must equal the proposed reference`)); + const target = typeof entry?.path === "string" ? path.resolve(root, entry.path) : ""; + if (!isRecord(entry) || !isHash(entry.sha256)) failures.push(finding(`baseline_${kind}_invalid`, file, `${kind} requires an exact path and 64-character hash`)); + if (!target || !target.startsWith(`${root}${path.sep}`) || !fs.existsSync(target)) failures.push(finding(`baseline_${kind}_missing`, file, `${kind} path must resolve inside the repository`)); + else if (fs.lstatSync(target).isSymbolicLink() || !fs.lstatSync(target).isFile() || !fs.realpathSync(target).startsWith(`${fs.realpathSync(root)}${path.sep}`)) failures.push(finding(`baseline_${kind}_file_invalid`, file, `${kind} must be a regular repository file, not a directory or symlink`)); + else if (entry.sha256 !== sha256(fs.readFileSync(target))) failures.push(finding(`baseline_${kind}_hash_mismatch`, file, `${kind} hash differs from repository bytes`)); + } + return failures; +} diff --git a/scripts/baseline-schema-parity.mjs b/scripts/baseline-schema-parity.mjs new file mode 100644 index 0000000..f32d8d9 --- /dev/null +++ b/scripts/baseline-schema-parity.mjs @@ -0,0 +1,40 @@ +import { BASELINE_METADATA_SHA256, BASELINE_REFERENCE } from "./baseline-contract.mjs"; + +const executionRepositories = ["ark-jo/StyleGallery", "changeroa/StyleGallery"]; + +export function baselineSchemaParity(schema, canonical, completedEvidence) { + const committedSchema = schema.properties.committed_ci.oneOf.find((entry) => entry.type === "object"); + const externalSchema = committedSchema.properties.external_verification?.oneOf?.find((entry) => entry.type === "object") ?? {}; + const artifactSchema = externalSchema.properties?.artifact ?? {}; + const relationshipSchema = externalSchema.properties?.repository_relationship ?? {}; + const runSchema = schema.properties.runs.items; + const runContains = schema.allOf[0].else.properties.runs.allOf; + return schema.additionalProperties === false + && committedSchema.additionalProperties === false + && schema.properties.environment.additionalProperties === false + && Object.entries(canonical.environment).every(([key, value]) => key === "viewport" + ? JSON.stringify(schema.properties.environment.properties.viewport.properties) === JSON.stringify({ width: { const: value.width }, height: { const: value.height } }) + : schema.properties.environment.properties[key].const === value) + && schema.properties.reference.additionalProperties === false + && schema.properties.reference.properties.source.additionalProperties === false + && schema.properties.reference.properties.source.properties.sha256.const === BASELINE_REFERENCE.source.sha256 + && schema.properties.reference.properties.baseline.properties.sha256.const === BASELINE_REFERENCE.baseline.sha256 + && runSchema.additionalProperties === false + && runSchema.properties.png_sha256.const === BASELINE_REFERENCE.baseline.sha256 + && runSchema.properties.metadata_sha256.const === BASELINE_METADATA_SHA256 + && committedSchema.properties.repository.const === "changeroa/StyleGallery" + && JSON.stringify(committedSchema.properties.execution_repository?.enum ?? []) === JSON.stringify(executionRepositories) + && externalSchema.additionalProperties === false + && artifactSchema.additionalProperties === false + && relationshipSchema.additionalProperties === false + && artifactSchema.properties?.api_digest?.const === completedEvidence.artifactApiDigest + && artifactSchema.properties?.id?.const === completedEvidence.artifactId + && artifactSchema.properties?.name?.const === completedEvidence.artifactName + && artifactSchema.properties?.size_in_bytes?.const === completedEvidence.artifactSize + && artifactSchema.properties?.expires_at?.const === completedEvidence.artifactExpiresAt + && externalSchema.properties?.source?.const === completedEvidence.source + && externalSchema.properties?.verified_at?.const === completedEvidence.verifiedAt + && JSON.stringify(runContains.map((entry) => entry.contains.properties.run.const).sort((left, right) => left - right)) === JSON.stringify(Array.from({ length: 20 }, (_, index) => index + 1)) + && runContains.every((entry) => entry.minContains === 1 && entry.maxContains === 1) + && JSON.stringify([...committedSchema.required].sort()) === JSON.stringify(["artifact_name", "checkout_sha", "execution_repository", "external_verification", "head_sha", "raw_evidence_sha256", "repository", "run_attempt", "run_id", "sha", "workflow"].sort()); +} diff --git a/scripts/build-reference-artifacts.mjs b/scripts/build-reference-artifacts.mjs new file mode 100644 index 0000000..d520c04 --- /dev/null +++ b/scripts/build-reference-artifacts.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import StyleDictionary from "style-dictionary"; +import { adapter, createStyleDictionaryConfig } from "../consumer-reference/adapters/style-dictionary.config.mjs"; +import { inspectArtifactPath } from "./reference-artifact-path-contract.mjs"; +import { validatePortableTokens } from "./reference-token-contract.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; + +const root = process.cwd(); +const defaults = { + adapter: "style-dictionary", + manifest: "consumer-reference/generated/manifest.json", + output: "consumer-reference/generated/tokens.css", + source: "consumer-reference/fixtures/token-portability/valid-reference.json", +}; + +function parseArguments() { + const options = { ...defaults, failOnWarning: false, json: false }; + for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") options.json = true; + else if (argument === "--fail-on-warning") options.failOnWarning = true; + else if (["--adapter", "--manifest", "--output", "--source"].includes(argument)) { + const value = process.argv[index + 1]; + if (!value) return { failure: { code: "argument_value_required", message: `${argument} requires a value`, path: "" }, options }; + options[argument.slice(2)] = value; + index += 1; + } else return { failure: { code: "argument_unknown", message: `unsupported argument ${argument}`, path: "" }, options }; + } + return { options }; +} + +function hash(content) { + return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`; +} + +function declarations(css) { + return [...css.matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);\s*$/gm)].map((match) => ({ name: match[1], value: match[2].trim() })); +} + +function emit(report, json) { + const text = json ? JSON.stringify(report, null, 2) : report.ok ? `ok: ${report.outputCount} token declarations` : report.failures.map((item) => `${item.code}: ${item.message}`).join("\n"); + (json || report.ok ? console.log : console.error)(text); +} + +const parsed = parseArguments(); +const options = parsed.options; +const source = path.resolve(root, options.source); +const output = path.resolve(root, options.output); +const manifestPath = path.resolve(root, options.manifest); +const failures = parsed.failure ? [parsed.failure] : []; +const warnings = []; +if (!parsed.failure) { + for (const [code, target] of [ + ["token_source_untrusted", source], + ["artifact_output_untrusted", output], + ["artifact_manifest_untrusted", manifestPath], + ]) { + const inspection = inspectArtifactPath(root, target, code !== "token_source_untrusted"); + if (!inspection.ok) failures.push({ code, message: `${target} must be a contained regular non-symlink file`, path: path.relative(root, target) || "." }); + } +} +let sourceBytes; +let document; +if (failures.length === 0) { + try { + sourceBytes = fs.readFileSync(source); + document = parseStrictJson(sourceBytes.toString("utf8")); + } catch (error) { + const code = error instanceof SyntaxError ? "token_json_invalid" : "token_source_unresolved"; + failures.push({ code, message: error instanceof Error ? error.message : String(error), path: options.source }); + } +} +let contract = { failures: [], tokens: [] }; +if (document !== undefined) { + contract = validatePortableTokens(document); + failures.push(...contract.failures); +} +if (options.adapter !== adapter.name) failures.push({ code: "adapter_unsupported", message: `adapter must be ${adapter.name}`, path: "" }); +if (failures.length === 0) { + fs.mkdirSync(path.dirname(output), { recursive: true }); + const dictionary = new StyleDictionary(createStyleDictionaryConfig(source, output), { verbosity: "silent", warnings: options.failOnWarning ? "error" : "warn" }); + try { + await dictionary.buildAllPlatforms(); + } catch (error) { + failures.push({ code: "adapter_build_failed", message: error instanceof Error ? error.message : String(error), path: options.source }); + } +} +let css = ""; +if (failures.length === 0 && !fs.existsSync(output)) failures.push({ code: "artifact_output_missing", message: "adapter produced no CSS output", path: options.output }); +if (failures.length === 0) { + css = fs.readFileSync(output, "utf8"); + const emitted = declarations(css); + const expected = contract.tokens.map((token) => `--${token.path.replaceAll(".", "-")}`); + const names = emitted.map((item) => item.name); + if (emitted.length === 0) failures.push({ code: "artifact_zero_count", message: "adapter produced zero declarations", path: options.output }); + if (new Set(names).size !== names.length) failures.push({ code: "artifact_declaration_duplicate", message: "adapter produced a duplicate declaration", path: options.output }); + for (const name of expected.filter((name) => !names.includes(name))) failures.push({ code: "artifact_declaration_missing", message: `adapter omitted ${name}`, path: options.output }); + if (css.includes("[object Object]")) failures.push({ code: "artifact_object_sentinel", message: "adapter emitted [object Object]", path: options.output }); + if (/\{[a-z0-9.-]+\}|\b(?:null|undefined)\b/.test(css)) failures.push({ code: "artifact_unresolved_value", message: "adapter emitted an unresolved value", path: options.output }); + if (contract.tokens.length !== emitted.length) failures.push({ code: "artifact_count_mismatch", message: "source and output token counts differ", path: options.output }); + if (warnings.length > 0 && options.failOnWarning) failures.push({ code: "adapter_warning", message: "adapter emitted a warning", path: options.output }); + if (failures.length === 0) { + const manifest = { + adapter, + declarations: emitted, + inputHash: hash(sourceBytes), + outputCount: emitted.length, + outputFile: path.relative(path.dirname(manifestPath), output).replaceAll(path.sep, "/"), + outputHash: hash(css), + schemaVersion: "1.0", + sourceCount: contract.tokens.length, + sourceFile: path.relative(path.dirname(manifestPath), source).replaceAll(path.sep, "/"), + warnings, + }; + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + } +} +const report = parsed.failure + ? { failures: [parsed.failure], ok: false, warnings } + : { adapter, failures, manifest: options.manifest, ok: failures.length === 0, output: options.output, outputCount: failures.length === 0 ? contract.tokens.length : 0, warnings }; +emit(report, options.json); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/calibration-raw-contract.mjs b/scripts/calibration-raw-contract.mjs new file mode 100644 index 0000000..72753bb --- /dev/null +++ b/scripts/calibration-raw-contract.mjs @@ -0,0 +1,172 @@ +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { BASELINE_ENVIRONMENT, BASELINE_METADATA_SHA256, BASELINE_REFERENCE, finding, sha256 } from "./baseline-contract.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; + +const EXPECTED_TEST = "canonical consumer reference preserves computed layout before its locator screenshot"; +const HASH_PATTERN = /^[0-9a-f]{64}$/; +const REQUIRED_FILES = new Set(["actual.png", "ax.txt", "comparison.json", "dom.html", "exit.json", "metadata.json", "playwright.json"]); +const ALLOWED_FILES = new Set([...REQUIRED_FILES, "playwright.stderr"]); +const FILE_LIMITS = new Map([ + ["actual.png", 5 * 1024 * 1024], ["ax.txt", 1024 * 1024], ["comparison.json", 1024 * 1024], + ["dom.html", 1024 * 1024], ["exit.json", 1024 * 1024], ["metadata.json", 1024 * 1024], + ["playwright.json", 10 * 1024 * 1024], ["playwright.stderr", 1024 * 1024], +]); + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function readJson(file, code, failures) { + if (!fs.existsSync(file)) { + failures.push(finding(code, file, "required raw calibration JSON is missing")); + return undefined; + } + try { + const stat = fs.lstatSync(file); + const limit = FILE_LIMITS.get(path.basename(file)) ?? 1024 * 1024; + if (stat.isSymbolicLink() || !stat.isFile() || stat.size > limit) throw new Error(`JSON evidence must be a regular file no larger than ${limit} bytes`); + return parseStrictJson(fs.readFileSync(file, "utf8")); + } catch (error) { + failures.push(finding(`${code}_invalid`, file, error instanceof Error ? error.message : String(error))); + return undefined; + } +} + +function rejectUnknown(value, allowed, code, file, failures) { + if (!isRecord(value)) return; + for (const key of Object.keys(value).filter((key) => !allowed.has(key))) { + failures.push(finding(code, file, `unsupported property ${key}`)); + } +} + +function validateExit(exit, expectedRun, file, failures) { + if (!isRecord(exit)) { + failures.push(finding("calibration_exit_invalid", file, "exit record must be an object")); + return false; + } + rejectUnknown(exit, new Set(["exit_code", "run", "schema_version"]), "calibration_exit_property_unknown", file, failures); + if (exit.schema_version !== "1.0" || exit.run !== expectedRun || exit.exit_code !== 0) { + failures.push(finding("calibration_exit_unsuccessful", file, "exit record must identify this run with exit code zero")); + return false; + } + return true; +} + +function validatePlaywright(report, file, failures) { + if (!isRecord(report)) { + failures.push(finding("calibration_playwright_result_invalid", file, "Playwright result must be an object")); + return false; + } + const project = report.config?.projects?.[0]; + const suite = report.suites?.[0]; + const spec = suite?.specs?.[0]; + const test = spec?.tests?.[0]; + const result = test?.results?.[0]; + const identityMatches = report.config?.version === "1.61.0" + && report.config?.workers === 1 + && report.config?.projects?.length === 1 + && project?.id === "chromium" + && project?.name === "chromium" + && report.suites?.length === 1 + && suite?.file === "consumer-reference-sentinels.spec.mjs" + && suite?.specs?.length === 1 + && spec?.title === EXPECTED_TEST + && spec?.tests?.length === 1 + && test?.projectId === "chromium" + && test?.projectName === "chromium"; + if (!identityMatches) failures.push(finding("calibration_playwright_identity", file, "result must contain exactly the canonical Chromium sentinel test")); + const completionMatches = spec?.ok === true + && test?.expectedStatus === "passed" + && test?.status === "expected" + && test?.results?.length === 1 + && result?.status === "passed" + && Array.isArray(result?.errors) + && result.errors.length === 0 + && Array.isArray(report.errors) + && report.errors.length === 0 + && report.stats?.expected === 1 + && report.stats?.skipped === 0 + && report.stats?.unexpected === 0 + && report.stats?.flaky === 0; + if (!completionMatches) failures.push(finding("calibration_playwright_incomplete", file, "canonical test must pass once with no skipped, flaky, interrupted, or unexpected result")); + return identityMatches && completionMatches; +} + +function validateMetadata(metadata, expectedRun, file, failures) { + if (!isRecord(metadata)) { + failures.push(finding("calibration_metadata_invalid", file, "metadata must be an object")); + return false; + } + const allowed = new Set(["architecture", "ax_sha256", "dom_sha256", "environment", "metadata_sha256", "png_sha256", "reference", "run", "schema_version"]); + rejectUnknown(metadata, allowed, "calibration_metadata_property_unknown", file, failures); + const hashesValid = [metadata.ax_sha256, metadata.dom_sha256, metadata.metadata_sha256, metadata.png_sha256].every((value) => typeof value === "string" && HASH_PATTERN.test(value)); + const valid = metadata.schema_version === "1.0" + && metadata.run === expectedRun + && metadata.architecture === "amd64" + && hashesValid + && metadata.metadata_sha256 === BASELINE_METADATA_SHA256 + && isDeepStrictEqual(metadata.environment, BASELINE_ENVIRONMENT) + && isDeepStrictEqual(metadata.reference, BASELINE_REFERENCE); + if (!valid) failures.push(finding("calibration_metadata_mismatch", file, "metadata shape, identity, hashes, environment, and reference must be canonical")); + return valid; +} + +function validateComparison(comparison, expectedRun, actualHash, file, failures) { + if (!isRecord(comparison)) { + failures.push(finding("calibration_comparison_invalid", file, "comparison proof must be an object")); + return false; + } + const allowed = new Set(["actual_sha256", "assertion", "diff_pixels", "expected_sha256", "max_diff_pixels", "run", "schema_version", "status", "threshold"]); + rejectUnknown(comparison, allowed, "calibration_comparison_property_unknown", file, failures); + const valid = comparison.schema_version === "1.0" + && comparison.run === expectedRun + && comparison.assertion === "visual_geometry_matches_proposed_baseline" + && comparison.status === "passed" + && comparison.threshold === 0 + && comparison.max_diff_pixels === 0 + && comparison.diff_pixels === 0 + && comparison.actual_sha256 === actualHash + && comparison.expected_sha256 === BASELINE_REFERENCE.baseline.sha256 + && comparison.actual_sha256 === comparison.expected_sha256; + if (!valid) failures.push(finding("calibration_comparison_mismatch", file, "comparison must prove the exact zero-diff baseline assertion")); + return valid; +} + +export function parseCalibrationRun(root, expectedRun, failures) { + const entries = fs.readdirSync(root, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !ALLOWED_FILES.has(entry.name)) failures.push(finding("calibration_raw_file_unknown", path.join(root, entry.name), "run directory contains unsupported or duplicate evidence")); + else if (fs.lstatSync(path.join(root, entry.name)).size > FILE_LIMITS.get(entry.name)) failures.push(finding("calibration_raw_file_oversized", path.join(root, entry.name), `${entry.name} exceeds its calibration evidence byte limit`)); + } + for (const name of REQUIRED_FILES) if (!entries.some((entry) => entry.isFile() && entry.name === name)) failures.push(finding("calibration_raw_file_missing", path.join(root, name), `${name} is required exactly once`)); + const metadata = readJson(path.join(root, "metadata.json"), "calibration_metadata_missing", failures); + const exit = readJson(path.join(root, "exit.json"), "calibration_exit_missing", failures); + const report = readJson(path.join(root, "playwright.json"), "calibration_playwright_missing", failures); + const comparison = readJson(path.join(root, "comparison.json"), "calibration_comparison_missing", failures); + const metadataValid = validateMetadata(metadata, expectedRun, path.join(root, "metadata.json"), failures); + const exitValid = validateExit(exit, expectedRun, path.join(root, "exit.json"), failures); + const reportValid = validatePlaywright(report, path.join(root, "playwright.json"), failures); + const artifactFields = [["actual.png", "png_sha256"], ["dom.html", "dom_sha256"], ["ax.txt", "ax_sha256"]]; + let artifactsValid = metadataValid; + for (const [name, field] of artifactFields) { + const file = path.join(root, name); + if (!fs.existsSync(file) || fs.lstatSync(file).isSymbolicLink() || !fs.lstatSync(file).isFile() || fs.lstatSync(file).size > FILE_LIMITS.get(name) || !metadata || sha256(fs.readFileSync(file)) !== metadata[field]) { + failures.push(finding("calibration_artifact_hash_mismatch", file, `${field} differs from raw artifact bytes`)); + artifactsValid = false; + } + } + const comparisonValid = validateComparison(comparison, expectedRun, metadata?.png_sha256, path.join(root, "comparison.json"), failures); + if (!(metadataValid && exitValid && reportValid && artifactsValid && comparisonValid)) return undefined; + return { + architecture: metadata.architecture, + ax_sha256: metadata.ax_sha256, + completed: true, + dom_sha256: metadata.dom_sha256, + metadata_sha256: metadata.metadata_sha256, + png_sha256: metadata.png_sha256, + run: expectedRun, + screenshot_diff_pixels: comparison.diff_pixels, + }; +} diff --git a/scripts/capture-session-contract.mjs b/scripts/capture-session-contract.mjs new file mode 100644 index 0000000..90594c0 --- /dev/null +++ b/scripts/capture-session-contract.mjs @@ -0,0 +1,187 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { resolveProfileRecords } from "./profile-record-contract.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; + +const captureSourcePaths = Object.freeze([ + "package-lock.json", + "package.json", + "playwright.config.mjs", + "consumer-reference/schema/ax-evidence.schema.json", + "consumer-reference/schema/capture-session.schema.json", + "consumer-reference/schema/component-state.schema.json", + "consumer-reference/schema/dom-evidence.schema.json", + "consumer-reference/schema/evidence-record.schema.json", + "consumer-reference/schema/fixture-manifest.schema.json", + "consumer-reference/schema/runtime-evidence-manifest.schema.json", + "consumer-reference/schema/visual-evidence.schema.json", + "scripts/artifact-metadata.mjs", + "scripts/capture-session-contract.mjs", + "scripts/component-state-contract.mjs", + "scripts/component-state-semantics.mjs", + "scripts/create-component-state-session.mjs", + "scripts/profile-record-contract.mjs", + "scripts/strict-json.mjs", + "scripts/visual-expectation-contract.mjs", + "tests/component-state-evidence.spec.mjs", + "tests/helpers/render-component-state.mjs", +]); + +function finding(code, file, message) { + return { code, message, path: file }; +} + +export function sha256(bytes) { + return `sha256:${crypto.createHash("sha256").update(bytes).digest("hex")}`; +} + +export function repositoryGitArgs(repositoryRoot, ...args) { + return ["-c", `safe.directory=${repositoryRoot}`, ...args]; +} + +function compare(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function profileSourceFiles(profileRoot) { + if (!fs.existsSync(profileRoot)) return []; + const sources = []; + const profiles = fs.readdirSync(profileRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(profileRoot, entry.name, "profile.json"))) + .sort((left, right) => compare(left.name, right.name)); + for (const profile of profiles) { + const profileFile = path.join(profileRoot, profile.name, "profile.json"); + const record = parseStrictJson(fs.readFileSync(profileFile, "utf8")); + const references = [ + "profile.json", + record.local_foundations, + record.tokens, + ...(record.component_records ?? []), + ...(record.fixture_records ?? []), + ...(record.state_records ?? []), + ]; + for (const reference of references) { + if (typeof reference !== "string") continue; + if (path.posix.isAbsolute(reference) || path.win32.isAbsolute(reference) || reference.includes("\\") || path.posix.normalize(reference) !== reference || reference.split("/").some((segment) => segment === "." || segment === "..")) { + throw new Error(`unsafe profile source reference ${profile.name}/${reference}`); + } + sources.push({ + file: path.join(profileRoot, profile.name, reference), + logicalPath: `profiles/${profile.name}/${reference.split(path.sep).join("/")}`, + }); + } + } + return sources; +} + +export function relevantSourceFiles(repositoryRoot, profileRoot) { + return [ + ...captureSourcePaths.map((reference) => ({ file: path.join(repositoryRoot, reference), logicalPath: reference })), + ...profileSourceFiles(profileRoot), + ].sort((left, right) => compare(left.logicalPath, right.logicalPath)); +} + +export function canonicalSourceManifest(repositoryRoot, profileRoot) { + const files = relevantSourceFiles(repositoryRoot, profileRoot).map(({ file, logicalPath }) => { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`capture source must be a regular non-symlink file: ${logicalPath}`); + const bytes = fs.readFileSync(file); + return { byte_length: bytes.length, path: logicalPath, sha256: sha256(bytes) }; + }); + return { files, sha256: sha256(Buffer.from(JSON.stringify(files))) }; +} + +export function sourceManifestMatches(source, repositoryRoot, profileRoot) { + try { + return sameJson(source, canonicalSourceManifest(repositoryRoot, profileRoot)); + } catch { + return false; + } +} + +export function dirtyRelevantSources(repositoryRoot, profileRoot) { + const repositoryPrefix = `${path.resolve(repositoryRoot)}${path.sep}`; + const tracked = relevantSourceFiles(repositoryRoot, profileRoot) + .map(({ file }) => path.resolve(file)) + .filter((file) => file.startsWith(repositoryPrefix)) + .map((file) => path.relative(repositoryRoot, file)); + if (tracked.length === 0) return []; + const output = execFileSync("git", repositoryGitArgs(repositoryRoot, "status", "--porcelain=v1", "--untracked-files=all", "--", ...tracked), { cwd: repositoryRoot, encoding: "utf8" }); + return output.split("\n").filter(Boolean).sort(compare); +} + +export function sessionLink(receipt, receiptSha256) { + return { + attempt: receipt.attempt, + branch: receipt.branch, + environment: receipt.environment, + nonce: receipt.nonce, + receipt_sha256: receiptSha256, + revision: receipt.revision, + session_id: receipt.session_id, + source: receipt.source, + started_at: receipt.started_at, + }; +} + +export function readCaptureSession(file, validate, failures) { + if (!fs.existsSync(file)) { + failures.push(finding("capture_session_missing", file, "capture session receipt is missing")); + return undefined; + } + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) { + failures.push(finding("capture_session_type_invalid", file, "capture session receipt must be a regular non-symlink file")); + return undefined; + } + const bytes = fs.readFileSync(file); + let receipt; + try { + receipt = parseStrictJson(bytes.toString("utf8")); + } catch (error) { + failures.push(finding("capture_session_json_invalid", file, error instanceof Error ? error.message : String(error))); + return undefined; + } + if (!validate(receipt)) { + for (const error of validate.errors ?? []) failures.push(finding("capture_session_schema_invalid", file, `${error.instancePath || "/"} ${error.message}`)); + } + const digest = sha256(bytes); + return { + artifact: { byte_length: bytes.length, media_type: "application/json", path: "capture-session.json", sha256: digest }, + bytes, + digest, + link: sessionLink(receipt, digest), + receipt, + }; +} + +export function canonicalIntended(profileRoot, failures) { + if (!fs.existsSync(profileRoot)) return []; + const intended = []; + for (const entry of fs.readdirSync(profileRoot, { withFileTypes: true }).filter((item) => item.isDirectory()).sort((left, right) => compare(left.name, right.name))) { + const root = path.join(profileRoot, entry.name); + if (!fs.existsSync(path.join(root, "profile.json"))) continue; + const resolved = resolveProfileRecords(root, failures); + const fixture = resolved?.records.fixture[0]?.value; + if (!resolved || !fixture) continue; + intended.push({ + profile_id: resolved.profile.id, + profile_name: entry.name, + scenarios: fixture.scenarios.map((scenario) => ({ channels: [...scenario.required_channels].sort(compare), id: scenario.id })).sort((left, right) => compare(left.id, right.id)), + }); + } + return intended; +} + +export function sameJson(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function withinSession(capturedAt, startedAt, completedAt) { + const captured = Date.parse(capturedAt); + const started = Date.parse(startedAt); + const completed = Date.parse(completedAt); + return Number.isFinite(captured) && Number.isFinite(started) && Number.isFinite(completed) && captured >= started && captured <= completed; +} diff --git a/scripts/component-state-artifact-fixture.mjs b/scripts/component-state-artifact-fixture.mjs new file mode 100644 index 0000000..5b77bf7 --- /dev/null +++ b/scripts/component-state-artifact-fixture.mjs @@ -0,0 +1,44 @@ +import zlib from "node:zlib"; + +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type, data) { + const name = Buffer.from(type, "ascii"); + const output = Buffer.alloc(12 + data.length); + output.writeUInt32BE(data.length, 0); + name.copy(output, 4); + data.copy(output, 8); + output.writeUInt32BE(crc32(Buffer.concat([name, data])), 8 + data.length); + return output; +} + +export function makePng(red) { + const width = 64; + const height = 64; + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 6; + const row = Buffer.alloc(1 + (width * 4)); + for (let pixel = 0; pixel < width; pixel += 1) row.set([red, 64, 128, 255], 1 + (pixel * 4)); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("IDAT", zlib.deflateSync(Buffer.concat(Array.from({ length: height }, () => row)))), + pngChunk("IEND", Buffer.alloc(0)), + ]); +} + +const canonicalPng = makePng(32); + +export function syntheticImage(profile, scenario) { + return Buffer.concat([canonicalPng, Buffer.from(`${profile}:${scenario}`)]); +} diff --git a/scripts/component-state-contract.mjs b/scripts/component-state-contract.mjs new file mode 100644 index 0000000..9a4c2f5 --- /dev/null +++ b/scripts/component-state-contract.mjs @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import path from "node:path"; +import Ajv2020 from "ajv/dist/2020.js"; +import { + validateComponentSemantics, + validateEvidenceSemantics, + validateFixtureSemantics, + validateStateSemantics, +} from "./component-state-semantics.mjs"; +import { validateEvidenceArtifacts as validateArtifactSet } from "./evidence-artifact-contract.mjs"; +import { resolveProfileRecords } from "./profile-record-contract.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; + +function finding(code, file, message) { + return { code, message, path: file }; +} + +export function readRecord(file, failures) { + try { + return parseStrictJson(fs.readFileSync(file, "utf8")); + } catch (error) { + failures.push(finding(error instanceof SyntaxError ? "record_invalid_json" : "record_unreadable", file, error.message)); + return undefined; + } +} + +function readSchema(schemaRoot, name) { + const file = path.join(schemaRoot, name); + try { + return parseStrictJson(fs.readFileSync(file, "utf8")); + } catch (cause) { + const error = new Error(`component schema is not strict JSON: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }); + error.code = "component_schema_json_invalid"; + error.path = file; + throw error; + } +} + +export function compileSchemas(schemaRoot) { + const ajv = new Ajv2020({ allErrors: true, formats: { "date-time": true }, strict: false }); + const captureSchema = readSchema(schemaRoot, "capture-session.schema.json"); + ajv.addSchema(captureSchema); + const evidenceSchema = readSchema(schemaRoot, "evidence-record.schema.json"); + const validateEvidence = ajv.compile(evidenceSchema); + return { + ax: ajv.compile(readSchema(schemaRoot, "ax-evidence.schema.json")), + capture: ajv.getSchema(captureSchema.$id), + component: ajv.compile(readSchema(schemaRoot, "component-state.schema.json")), + dom: ajv.compile(readSchema(schemaRoot, "dom-evidence.schema.json")), + evidence: validateEvidence, + fixture: ajv.compile(readSchema(schemaRoot, "fixture-manifest.schema.json")), + item: ajv.compile(readSchema(schemaRoot, "item.schema.json")), + runtime: ajv.compile(readSchema(schemaRoot, "runtime-evidence-manifest.schema.json")), + visual: ajv.compile(readSchema(schemaRoot, "visual-evidence.schema.json")), + }; +} + +function addSchemaFailures(validate, value, file, code, failures) { + if (validate(value)) return; + for (const error of validate.errors ?? []) failures.push(finding(code, file, `${error.instancePath || "/"} ${error.message}`)); +} + +export function validateProfile(profileRoot, schemas) { + const failures = []; + const resolved = resolveProfileRecords(profileRoot, failures); + if (!resolved) return failures; + addSchemaFailures(schemas.item, resolved.profile, resolved.profileFile, "profile_schema_invalid", failures); + const files = Object.fromEntries(["component", "evidence", "fixture", "states"].map((kind) => [kind, resolved.records[kind][0]?.path ?? resolved.profileFile])); + const records = Object.fromEntries(["component", "evidence", "fixture", "states"].map((kind) => [kind, resolved.records[kind][0]?.value])); + if (records.component) addSchemaFailures(schemas.component, records.component, files.component, "component_schema_invalid", failures); + if (records.states) addSchemaFailures(schemas.component, records.states, files.states, "state_schema_invalid", failures); + if (records.fixture) addSchemaFailures(schemas.fixture, records.fixture, files.fixture, "fixture_schema_invalid", failures); + if (records.evidence) { + addSchemaFailures(schemas.evidence, records.evidence, files.evidence, "evidence_schema_invalid", failures); + } + if (records.component) validateComponentSemantics(records.component, files.component, failures); + if (records.states && records.component) validateStateSemantics(records.states, records.component, files.states, failures); + if (records.fixture && records.states && records.component) validateFixtureSemantics(records.fixture, records.states, records.component, files.fixture, failures); + if (records.fixture && records.states && records.evidence) validateEvidenceSemantics(records.evidence, records.fixture, records.states, files.evidence, failures); + return failures; +} + +export function validateEvidenceArtifacts(profileRoot, artifactRoot, evidenceOverride, schemas, session) { + const failures = []; + const resolved = resolveProfileRecords(profileRoot, failures); + const evidenceRecord = resolved?.records.evidence[0]; + const file = evidenceRecord?.path ?? resolved?.profileFile ?? path.join(profileRoot, "profile.json"); + const evidence = evidenceOverride ?? evidenceRecord?.value; + const fixture = resolved?.records.fixture[0]?.value; + const states = resolved?.records.states[0]?.value; + if (evidence && fixture && states) { + if (evidenceOverride) { + addSchemaFailures(schemas.evidence, evidence, file, "evidence_schema_invalid", failures); + validateEvidenceSemantics(evidence, fixture, states, file, failures); + } + failures.push(...validateArtifactSet({ artifactRoot, evidence, evidenceFile: file, fixture, profileId: resolved.profile.id, schemas, session, states })); + } + return failures; +} diff --git a/scripts/component-state-semantics.mjs b/scripts/component-state-semantics.mjs new file mode 100644 index 0000000..0e0c1f3 --- /dev/null +++ b/scripts/component-state-semantics.mjs @@ -0,0 +1,128 @@ +function finding(code, file, message) { + return { code, message, path: file }; +} + +function duplicateValues(values, normalize = (value) => value) { + const seen = new Set(); + const duplicates = new Set(); + for (const value of values) { + const key = normalize(value); + if (seen.has(key)) duplicates.add(key); + seen.add(key); + } + return [...duplicates]; +} + +function sameSet(left, right) { + return JSON.stringify([...(left ?? [])].sort()) === JSON.stringify([...(right ?? [])].sort()); +} + +function validateApplicability(component, file, failures) { + for (const id of duplicateValues((component.semantic_modes ?? []).map((mode) => mode.id))) failures.push(finding("semantic_mode_duplicate", file, `semantic mode ${id} is duplicated`)); + for (const mode of component.semantic_modes ?? []) { + for (const name of duplicateValues((mode.aria_states ?? []).map((state) => state.name))) failures.push(finding("aria_state_duplicate", file, `${mode.id} repeats ${name}`)); + for (const state of mode.aria_states ?? []) { + if (["not_applicable", "prohibited"].includes(state.status) && !state.reason) failures.push(finding("applicability_reason_required", file, `${mode.id}.${state.name} requires a reason`)); + if (state.status === "conditional" && (!state.condition || !state.resolution)) failures.push(finding("conditional_unresolved", file, `${mode.id}.${state.name} requires condition and resolution`)); + if (state.status === "conditional" && ["not_applicable", "prohibited"].includes(state.resolution) && !state.reason) failures.push(finding("applicability_reason_required", file, `${mode.id}.${state.name} requires a resolution reason`)); + } + const required = mode.id === "toggle" ? "pressed" : mode.id === "disclosure" ? "expanded" : mode.id === "switch" ? "checked" : undefined; + const state = (mode.aria_states ?? []).find((candidate) => candidate.name === required); + if (required && (!state || !["required", "conditional"].includes(state.status) || (state.status === "conditional" && state.resolution !== "required"))) { + failures.push(finding("role_required_aria_state", file, `${mode.id} requires applicable aria-${required}`)); + } + if (mode.id === "switch" && (mode.aria_states ?? []).find((state) => state.name === "checked")?.values?.includes("mixed")) { + failures.push(finding("switch_mixed_prohibited", file, "switch aria-checked must not include mixed")); + } + } +} + +function validateSurface(scenario, state, visual, aria, dom, ax, code, failures, file) { + const present = new Set(scenario.states ?? []).has(state); + const surfaces = [ + (scenario.expected?.visual ?? []).includes(visual), + aria ? scenario.aria?.[aria] === "true" : present, + dom ? scenario.expected?.dom?.[dom] === "true" : present, + ax ? scenario.expected?.ax?.[ax] === true : present, + ]; + if (surfaces.some((value) => value !== present)) failures.push(finding(code, file, `${scenario.id} ${state} surfaces disagree`)); +} + +export function validateComponentSemantics(component, file, failures) { + validateApplicability(component, file, failures); + if (component.versions?.delivery_channel === "stable" && component.versions.lifecycle !== "stable") failures.push(finding("preview_rolled_stable", file, "preview components cannot be relabeled stable")); +} + +export function validateStateSemantics(states, component, file, failures) { + const scenarios = states.scenarios ?? []; + const visualEnvironments = states.visual_environments ?? []; + const visualEnvironmentIds = new Set(visualEnvironments.map((environment) => environment.id)); + for (const id of duplicateValues(visualEnvironments.map((environment) => environment.id))) failures.push(finding("visual_environment_duplicate", file, `visual environment ${id} is duplicated`)); + for (const selector of duplicateValues(visualEnvironments, (environment) => JSON.stringify(Object.entries(environment).filter(([key]) => key !== "id").sort()))) failures.push(finding("visual_environment_selector_duplicate", file, `visual environment selector ${selector} is duplicated`)); + for (const id of duplicateValues(scenarios.map((scenario) => scenario.id))) failures.push(finding("scenario_duplicate", file, `scenario ${id} is duplicated`)); + for (const key of duplicateValues(scenarios, (scenario) => [...(scenario.states ?? [])].sort().join("\u0000"))) failures.push(finding("state_set_duplicate", file, `state set ${key} is duplicated`)); + const modes = new Map((component.semantic_modes ?? []).map((mode) => [mode.id, mode])); + for (const scenario of scenarios) { + const visualImages = scenario.expected?.visual_image ?? []; + for (const id of duplicateValues(visualImages.map((image) => image.environment_id))) failures.push(finding("visual_expectation_duplicate", file, `${scenario.id} repeats visual expectation ${id}`)); + for (const image of visualImages) if (!visualEnvironmentIds.has(image.environment_id)) failures.push(finding("visual_environment_unknown", file, `${scenario.id} references unknown visual environment ${image.environment_id}`)); + for (const id of visualEnvironmentIds) if (!visualImages.some((image) => image.environment_id === id)) failures.push(finding("visual_expectation_missing", file, `${scenario.id} lacks visual expectation ${id}`)); + const stateSet = new Set(scenario.states ?? []); + const mode = modes.get(scenario.semantic_mode); + if (!mode) failures.push(finding("scenario_mode_unknown", file, `${scenario.id} references an unknown semantic mode`)); + if (scenario.expected?.dom?.role !== mode?.role || scenario.expected?.ax?.role !== mode?.role) failures.push(finding("role_surface_mismatch", file, `${scenario.id} role surfaces disagree with its semantic mode`)); + validateSurface(scenario, "focus", "focus", undefined, "active", "focused", "focus_surface_mismatch", failures, file); + validateSurface(scenario, "pressed", "pressed", "pressed", "aria-pressed", "pressed", "pressed_surface_mismatch", failures, file); + validateSurface(scenario, "expanded", "expanded", "expanded", "aria-expanded", "expanded", "expanded_surface_mismatch", failures, file); + validateSurface(scenario, "busy", "busy", "busy", "aria-busy", "busy", "busy_surface_mismatch", failures, file); + validateSurface(scenario, "disabled", "disabled", "disabled", "disabled", "disabled", "disabled_surface_mismatch", failures, file); + if (stateSet.has("loading") && (!stateSet.has("busy") || !(scenario.expected?.visual ?? []).includes("loading"))) failures.push(finding("loading_busy_state_required", file, `${scenario.id} loading requires distinct visible busy state`)); + if (stateSet.has("loading") && (scenario.expected?.visual ?? []).includes("disabled") && !stateSet.has("disabled")) failures.push(finding("loading_disabled_conflated", file, `${scenario.id} renders loading as disabled`)); + if (stateSet.has("disabled") && scenario.expected?.activation !== "suppressed") failures.push(finding("disabled_activation", file, `${scenario.id} disabled activation must be suppressed`)); + if (!stateSet.has("disabled") && !stateSet.has("loading") && scenario.expected?.activation === "suppressed") failures.push(finding("normative_activation_conflict", file, `${scenario.id} suppresses activation without a suppressing state`)); + } +} + +export function validateFixtureSemantics(fixture, states, component, file, failures) { + const canonical = new Map((states.scenarios ?? []).map((scenario) => [scenario.id, scenario])); + const modes = new Map((component.semantic_modes ?? []).map((mode) => [mode.id, mode])); + for (const id of duplicateValues((fixture.scenarios ?? []).map((scenario) => scenario.id))) failures.push(finding("fixture_scenario_duplicate", file, `fixture scenario ${id} is duplicated`)); + for (const scenario of fixture.scenarios ?? []) { + const source = canonical.get(scenario.id); + if (!source) failures.push(finding("fixture_scenario_missing", file, `${scenario.id} has no canonical state scenario`)); + else if (source.semantic_mode !== scenario.semantic_mode) failures.push(finding("normative_mode_conflict", file, `${scenario.id} semantic modes disagree`)); + if (!modes.get(scenario.semantic_mode)?.keys?.includes(scenario.activation_key)) failures.push(finding("fixture_activation_key_invalid", file, `${scenario.id} activation key is not declared by its mode`)); + } + for (const id of canonical.keys()) if (!(fixture.scenarios ?? []).some((scenario) => scenario.id === id)) failures.push(finding("fixture_scenario_unexercised", file, `${id} lacks a runtime fixture`)); +} + +export function validateEvidenceSemantics(evidence, fixture, states, file, failures) { + if (Object.hasOwn(evidence, "aggregate_pass") || Object.hasOwn(evidence, "certification")) failures.push(finding("aggregate_pass_forbidden", file, "aggregate pass and certification claims are forbidden")); + const canonical = new Map((states.scenarios ?? []).map((scenario) => [scenario.id, scenario])); + const fixtures = new Map((fixture.scenarios ?? []).map((scenario) => [scenario.id, scenario])); + const passes = evidence.passes ?? []; + for (const id of duplicateValues(passes.map((pass) => pass.id))) failures.push(finding("evidence_pass_duplicate", file, `evidence pass ${id} is duplicated`)); + for (const key of duplicateValues(passes, (pass) => `${pass.scenario_id}\u0000${pass.channel}`)) failures.push(finding("evidence_channel_duplicate", file, `scenario channel ${key} is duplicated`)); + for (const artifact of duplicateValues(passes.map((pass) => pass.artifact?.path).filter(Boolean))) failures.push(finding("evidence_artifact_reused", file, `artifact ${artifact} is reused`)); + for (const digest of duplicateValues(passes.map((pass) => pass.artifact?.sha256).filter(Boolean))) failures.push(finding("evidence_artifact_content_reused", file, `artifact content ${digest} is reused`)); + const runIdentities = new Set(passes.map((pass) => JSON.stringify(pass.run))); + const environments = new Set(passes.map((pass) => JSON.stringify(pass.environment))); + if (runIdentities.size > 1 || environments.size > 1) failures.push(finding("evidence_runtime_identity_mismatch", file, "passes must share one actual run and environment")); + const sessions = new Set(passes.map((pass) => JSON.stringify(pass.session))); + if (sessions.size > 1) failures.push(finding("capture_session_mismatch", file, "passes must share one capture session")); + for (const pass of passes) { + const source = canonical.get(pass.scenario_id); + const scenario = fixtures.get(pass.scenario_id); + if (!source || !scenario) failures.push(finding("evidence_scenario_unknown", file, `${pass.id} references unknown scenario ${pass.scenario_id}`)); + if (pass.channel === "at" || pass.environment?.kind === "assistive_technology") failures.push(finding("at_evidence_unverified", file, `${pass.id} self-asserts an AT run without external attestation`)); + if (pass.session && (pass.run?.id !== pass.session.session_id || pass.run?.revision !== pass.session.revision || pass.run?.attempt !== pass.session.attempt || !sameSet([JSON.stringify(pass.environment)], [JSON.stringify(pass.session.environment)]))) failures.push(finding("capture_session_mismatch", file, `${pass.id} run or environment differs from its capture session`)); + if (scenario && !(scenario.required_channels ?? []).includes(pass.channel)) failures.push(finding("evidence_channel_mismatch", file, `${pass.id} channel is not required for its scenario`)); + if (source && (!sameSet(pass.scope?.state_set, source.states) || pass.scope?.semantic_mode !== source.semantic_mode)) failures.push(finding("evidence_scope_mismatch", file, `${pass.id} scope differs from canonical state JSON`)); + const expectedMedia = pass.channel === "visual" ? "image/png" : ["dom", "ax"].includes(pass.channel) ? "application/json" : undefined; + if (expectedMedia && pass.artifact?.media_type !== expectedMedia) failures.push(finding("evidence_media_type_mismatch", file, `${pass.id} media type does not match its channel`)); + } + for (const scenario of fixture.scenarios ?? []) { + const channels = new Set(passes.filter((pass) => pass.scenario_id === scenario.id).map((pass) => pass.channel)); + for (const channel of scenario.required_channels ?? []) if (!channels.has(channel)) failures.push(finding("required_channel_missing", file, `${scenario.id} lacks ${channel} evidence`)); + } +} diff --git a/scripts/component-state-workflow-contract.mjs b/scripts/component-state-workflow-contract.mjs new file mode 100644 index 0000000..102ffdc --- /dev/null +++ b/scripts/component-state-workflow-contract.mjs @@ -0,0 +1,67 @@ +const artifactHarness = "node scripts/test-validate-component-state-artifacts.mjs"; +const pinnedContainer = "mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a"; +const sharedRoot = "$STATE_EVIDENCE_ROOT"; + +function jobSection(workflow, jobName) { + const marker = ` ${jobName}:\n`; + const start = workflow.indexOf(marker); + if (start === -1) return ""; + const remainder = workflow.slice(start + marker.length); + const nextJob = remainder.search(/^ [a-z0-9][a-z0-9-]*:\s*$/m); + return nextJob === -1 ? remainder : remainder.slice(0, nextJob); +} + +function normalized(line) { + return line.trim().replace(/\s*\\$/, ""); +} + +function commandCount(section, command) { + return section.split("\n").filter((line) => normalized(line).replace(/^(?:-\s+)?run:\s+/, "") === command).length; +} + +function invocation(section, command) { + const lines = section.split("\n"); + const starts = lines.flatMap((line, index) => normalized(line) === command ? [index] : []); + if (starts.length !== 1) return []; + const start = starts[0]; + const indentation = lines[start].search(/\S/); + const block = [command]; + for (let index = start + 1; index < lines.length; index += 1) { + if (lines[index].trim() && lines[index].search(/\S/) <= indentation) break; + if (lines[index].trim()) block.push(normalized(lines[index])); + } + return block; +} + +function hasAdjacentLines(section, first, second) { + const lines = section.split("\n"); + return lines.some((line, index) => line === first && lines[index + 1] === second); +} + +export function componentStateWorkflowFailures(workflow) { + const failures = []; + const validateJob = jobSection(workflow, "validate"); + const componentJob = jobSection(workflow, "component-state-evidence"); + const totalHarnesses = commandCount(workflow, artifactHarness); + const componentHarnesses = commandCount(componentJob, artifactHarness); + if (commandCount(validateJob, artifactHarness) > 0) failures.push("browser-dependent artifact/session harness must not run in validate job"); + if (componentHarnesses === 0) failures.push("component-state artifact/session harness must run in Playwright container job"); + if (totalHarnesses !== 1 || componentHarnesses !== 1) failures.push("artifact/session harness must run exactly once and only in component-state-evidence job"); + if (!hasAdjacentLines(componentJob, " container:", ` image: ${pinnedContainer}`)) failures.push("component-state container.image must equal pinned Playwright digest"); + if (/runner\.temp|RUNNER_TEMP/.test(componentJob)) failures.push("component-state Playwright container job must not use runner temp paths"); + if (!hasAdjacentLines(componentJob, " env:", " STATE_EVIDENCE_ROOT: .tmp/consumer-reference-state")) failures.push("missing shared component-state workspace path STATE_EVIDENCE_ROOT: .tmp/consumer-reference-state"); + if (!componentJob.includes(` env:\n STATE_EVIDENCE_ROOT: .tmp/consumer-reference-state\n SENTINEL_CONTAINER_IMAGE: ${pinnedContainer}`)) failures.push("component-state job must export the pinned Playwright image identity"); + + const runtime = invocation(componentJob, `STATE_ARTIFACT_DIR="${sharedRoot}/runtime"`); + if (!runtime.includes(`STATE_SESSION_RECEIPT="${sharedRoot}/capture-session.json"`)) failures.push("component-state runtime must bind receipt under shared root"); + const finalizer = invocation(componentJob, "node scripts/finalize-component-state-evidence.mjs"); + if (!finalizer.includes(`--artifact-root "${sharedRoot}"`)) failures.push("component-state finalizer must use shared artifact root"); + if (!finalizer.includes(`--output "${sharedRoot}/runtime-manifest.json"`)) failures.push("component-state finalizer must write manifest under shared root"); + const validator = invocation(componentJob, "node scripts/validate-component-state.mjs"); + if (!validator.includes(`--artifact-root "${sharedRoot}"`)) failures.push("component-state validator must use shared artifact root"); + if (!validator.includes(`--runtime-manifest "${sharedRoot}/runtime-manifest.json"`)) failures.push("component-state validator must read manifest under shared root"); + const creator = invocation(componentJob, "node scripts/create-component-state-session.mjs"); + if (!creator.includes(`--output "${sharedRoot}/capture-session.json"`)) failures.push("missing shared component-state workspace path --output \"$STATE_EVIDENCE_ROOT/capture-session.json\""); + if (!componentJob.split("\n").includes(" path: ${{ env.STATE_EVIDENCE_ROOT }}/")) failures.push("missing shared component-state workspace path path: ${{ env.STATE_EVIDENCE_ROOT }}/"); + return failures; +} diff --git a/scripts/component-state-workflow-negative-cases.mjs b/scripts/component-state-workflow-negative-cases.mjs new file mode 100644 index 0000000..49c7de9 --- /dev/null +++ b/scripts/component-state-workflow-negative-cases.mjs @@ -0,0 +1,64 @@ +export function componentStateWorkflowCases(workflow) { + return [ + { + name: "browser_artifact_harness_in_static_job", + mutate: { ".github/workflows/validate.yml": workflow.replace(" validate:\n", " validate:\nnode scripts/test-validate-component-state-artifacts.mjs\n") }, + expect: ".github/workflows/validate.yml: browser-dependent artifact/session harness must not run in validate job", + }, + { + name: "browser_artifact_harness_missing_container_job", + mutate: { ".github/workflows/validate.yml": workflow.replace(" run: node scripts/test-validate-component-state-artifacts.mjs\n", "") }, + expect: ".github/workflows/validate.yml: component-state artifact/session harness must run in Playwright container job", + }, + { + name: "duplicate_artifact_harness_in_chromium_sentinel", + mutate: { ".github/workflows/validate.yml": workflow.replace(" - run: node scripts/test-consumer-reference-sentinel.mjs\n", " - run: node scripts/test-consumer-reference-sentinel.mjs\n - run: node scripts/test-validate-component-state-artifacts.mjs\n") }, + expect: ".github/workflows/validate.yml: artifact/session harness must run exactly once and only in component-state-evidence job", + }, + { + name: "unpinned_component_container_with_pinned_env", + mutate: { ".github/workflows/validate.yml": workflow.replace(" image: mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a", " image: mcr.microsoft.com/playwright:v1.61.0-noble") }, + expect: ".github/workflows/validate.yml: component-state container.image must equal pinned Playwright digest", + }, + { + name: "session_receipt_outside_shared_root", + mutate: { ".github/workflows/validate.yml": workflow.replace(" STATE_SESSION_RECEIPT=\"$STATE_EVIDENCE_ROOT/capture-session.json\" \\\n", " STATE_SESSION_RECEIPT=\"/tmp/capture-session.json\" \\\n") }, + expect: ".github/workflows/validate.yml: component-state runtime must bind receipt under shared root", + }, + { + name: "finalizer_output_outside_shared_root", + mutate: { ".github/workflows/validate.yml": workflow.replace(" --output \"$STATE_EVIDENCE_ROOT/runtime-manifest.json\" \\\n", " --output \"/tmp/runtime-manifest.json\" \\\n") }, + expect: ".github/workflows/validate.yml: component-state finalizer must write manifest under shared root", + }, + { + name: "validator_manifest_outside_shared_root", + mutate: { ".github/workflows/validate.yml": workflow.replace(" --runtime-manifest \"$STATE_EVIDENCE_ROOT/runtime-manifest.json\" \\\n", " --runtime-manifest \"/tmp/runtime-manifest.json\" \\\n") }, + expect: ".github/workflows/validate.yml: component-state validator must read manifest under shared root", + }, + { + name: "finalizer_artifact_root_outside_shared_root", + mutate: { ".github/workflows/validate.yml": workflow.replace(" node scripts/finalize-component-state-evidence.mjs \\\n --artifact-root \"$STATE_EVIDENCE_ROOT\" \\\n", " node scripts/finalize-component-state-evidence.mjs \\\n --artifact-root \"/tmp/consumer-reference-state\" \\\n") }, + expect: ".github/workflows/validate.yml: component-state finalizer must use shared artifact root", + }, + { + name: "validator_artifact_root_outside_shared_root", + mutate: { ".github/workflows/validate.yml": workflow.replace(" node scripts/validate-component-state.mjs \\\n --artifact-root \"$STATE_EVIDENCE_ROOT\" \\\n", " node scripts/validate-component-state.mjs \\\n --artifact-root \"/tmp/consumer-reference-state\" \\\n") }, + expect: ".github/workflows/validate.yml: component-state validator must use shared artifact root", + }, + { + name: "runner_temp_in_component_job", + mutate: { ".github/workflows/validate.yml": workflow.replace("STATE_EVIDENCE_ROOT: .tmp/consumer-reference-state", "STATE_EVIDENCE_ROOT: ${{ runner.temp }}/consumer-reference-state") }, + expect: ".github/workflows/validate.yml: component-state Playwright container job must not use runner temp paths", + }, + { + name: "component_state_workspace_root_drift", + mutate: { ".github/workflows/validate.yml": workflow.replaceAll(".tmp/consumer-reference-state", "state-output") }, + expect: ".github/workflows/validate.yml: missing shared component-state workspace path STATE_EVIDENCE_ROOT: .tmp/consumer-reference-state", + }, + { + name: "missing_component_state_image_identity", + mutate: { ".github/workflows/validate.yml": workflow.replace(" SENTINEL_CONTAINER_IMAGE: mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a\n", "") }, + expect: ".github/workflows/validate.yml: component-state job must export the pinned Playwright image identity", + }, + ]; +} diff --git a/scripts/consumer-conformance-contract.mjs b/scripts/consumer-conformance-contract.mjs new file mode 100644 index 0000000..fc98f55 --- /dev/null +++ b/scripts/consumer-conformance-contract.mjs @@ -0,0 +1,198 @@ +import { isPlainObject } from "./consumer-reference-schema.mjs"; + +export const MIGRATION_DIMENSIONS = Object.freeze([ + "behavior_inventory", + "route_parity", + "field_parity", + "action_parity", + "state_transitions", + "contract_precedence", + "direct_mutation", + "indirect_mutation", + "persistence_round_trip", + "reset_boundary", + "exact_time_boundary", + "defaults_tri_state_mapping", + "atomic_batch_behavior", +]); + +export const RUNTIME_EVIDENCE_METHODS = Object.freeze(["unit", "integration", "browser"]); + +const sentencePattern = /^\S+(?:\s+\S+){2,}\s*[.!?]$/; +const revisionPattern = /^[a-f0-9]{40}$/; +const componentPattern = /^[A-Z][A-Za-z0-9]*(?:[._/-][A-Za-z0-9_-]+)*$/; +const selectorPattern = /^(?:[.#][A-Za-z_][A-Za-z0-9_-]*|\[[A-Za-z_:][-A-Za-z0-9_:.]*(?:=[^\]\r\n]+)?\])$/; + +function finding(code, message, recordPath) { + return { code, message, path: recordPath }; +} + +function uniqueFindings(findings) { + return [...new Map(findings.map((entry) => [`${entry.code}:${entry.path}:${entry.message}`, entry])).values()]; +} + +function sameStringSet(left, right) { + return Array.isArray(left) + && Array.isArray(right) + && left.length === right.length + && new Set(left).size === left.length + && left.every((entry) => right.includes(entry)); +} + +export function isNormalizedRepositoryPath(value, { jsonOnly = false } = {}) { + if (typeof value !== "string" || value.length === 0 || value.startsWith("/") || value.endsWith("/") || value.includes("\\") || value.includes("//") || value.includes("?") || value.includes("#")) return false; + if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) return false; + if (value.split("/").some((segment) => segment === "." || segment === ".." || segment.length === 0)) return false; + return !jsonOnly || value.endsWith(".json"); +} + +function isSentence(value) { + return typeof value === "string" && value.length >= 10 && sentencePattern.test(value); +} + +function isConsumerTarget(target) { + if (!isPlainObject(target) || typeof target.identity !== "string") return false; + if (target.kind === "component") return componentPattern.test(target.identity); + if (target.kind === "selector") return selectorPattern.test(target.identity); + if (target.kind === "module") return isNormalizedRepositoryPath(target.identity); + return false; +} + +function schemaCode(error) { + const pointer = error.instancePath ?? ""; + const missing = error.params?.missingProperty; + if (pointer === "/migration_dimensions" && MIGRATION_DIMENSIONS.includes(missing)) return "migration_dimension_required"; + if (/^\/migration_dimensions\/[^/]+\/scenario_ids$/.test(pointer) && error.keyword === "minItems") return "migration_dimension_scenario_required"; + if (/^\/scenarios\/\d+\/evidence_method$/.test(pointer)) return "runtime_evidence_method_invalid"; + if (/^\/adoption_mappings\/\d+\/consumer_target/.test(pointer)) return "adoption_consumer_target_invalid"; + if (/^\/adoption_mappings\/\d+\/stylegallery\/revision$/.test(pointer)) return "adoption_stylegallery_revision_unpinned"; + if (/^\/adoption_mappings\/\d+\/debt\/\d+/.test(pointer)) return "adoption_debt_incomplete"; + return "consumer_conformance_schema_invalid"; +} + +export function consumerConformanceSchemaFindings(errors, recordPath) { + return uniqueFindings((errors ?? []).map((error) => finding( + schemaCode(error), + `${error.instancePath || "/"} ${error.message ?? "does not satisfy the schema"}`, + recordPath, + ))); +} + +export function validateConsumerConformanceSemantics(value, recordPath) { + const findings = []; + const add = (code, message) => findings.push(finding(code, message, recordPath)); + if (!isPlainObject(value)) { + add("consumer_conformance_record_invalid", "consumer conformance record must be a JSON object"); + return findings; + } + + const scenarios = Array.isArray(value.scenarios) ? value.scenarios.filter(isPlainObject) : []; + const scenarioIds = scenarios.map((scenario) => scenario.id).filter((id) => typeof id === "string"); + const scenarioSet = new Set(scenarioIds); + if (scenarioIds.length !== scenarioSet.size) add("runtime_scenario_id_duplicate", "runtime scenario IDs must be unique"); + for (const scenario of scenarios) { + if (!RUNTIME_EVIDENCE_METHODS.includes(scenario.evidence_method)) add("runtime_evidence_method_invalid", "runtime evidence must use unit, integration, or browser execution"); + if (["unit", "integration"].includes(scenario.evidence_method) && scenario.argv?.[0] !== "node") add("runtime_command_executable_invalid", "unit and integration evidence must use the governed Node runtime"); + if (scenario.exit_code !== 0) add("runtime_evidence_exit_nonzero", `scenario ${scenario.id ?? ""} must record exit code zero`); + if (!isNormalizedRepositoryPath(scenario.result_artifact, { jsonOnly: true })) add("runtime_result_artifact_invalid", `scenario ${scenario.id ?? ""} requires a normalized JSON result artifact`); + } + + const dimensions = isPlainObject(value.migration_dimensions) ? value.migration_dimensions : {}; + for (const dimensionName of MIGRATION_DIMENSIONS) { + if (!Object.hasOwn(dimensions, dimensionName)) { + add("migration_dimension_required", `migration dimension ${dimensionName} must be explicitly classified`); + continue; + } + const dimension = dimensions[dimensionName]; + if (!isPlainObject(dimension)) continue; + if (dimension.status === "applicable") { + if (!Array.isArray(dimension.scenario_ids) || dimension.scenario_ids.length === 0) { + add("migration_dimension_scenario_required", `applicable migration dimension ${dimensionName} requires a runtime scenario`); + } else { + for (const scenarioId of dimension.scenario_ids) { + if (!scenarioSet.has(scenarioId)) add("migration_dimension_scenario_unknown", `migration dimension ${dimensionName} references unknown scenario ${scenarioId}`); + } + } + } else if (dimension.status === "not_applicable" && !isSentence(dimension.reason)) { + add("migration_dimension_reason_invalid", `not-applicable migration dimension ${dimensionName} requires a sentence reason`); + } + } + + const mappings = Array.isArray(value.adoption_mappings) ? value.adoption_mappings.filter(isPlainObject) : []; + for (const [index, mapping] of mappings.entries()) { + if (!isConsumerTarget(mapping.consumer_target)) add("adoption_consumer_target_invalid", `adoption mapping ${index} requires a normalized local component, selector, or module identity`); + if (!revisionPattern.test(mapping.stylegallery?.revision ?? "")) add("adoption_stylegallery_revision_unpinned", `adoption mapping ${index} requires a full StyleGallery revision`); + for (const scenarioId of Array.isArray(mapping.scenario_ids) ? mapping.scenario_ids : []) { + if (!scenarioSet.has(scenarioId)) add("adoption_scenario_unknown", `adoption mapping ${index} references unknown scenario ${scenarioId}`); + } + for (const debt of Array.isArray(mapping.debt) ? mapping.debt : []) { + if (!isPlainObject(debt) || typeof debt.owner !== "string" || debt.owner.length === 0 || !isSentence(debt.review_trigger) || typeof debt.review_by !== "string") { + add("adoption_debt_incomplete", `adoption mapping ${index} debt requires owner, review trigger, and review-by date`); + } + } + } + + if (value.page_evidence?.status === "applicable" && !isNormalizedRepositoryPath(value.page_evidence.manifest, { jsonOnly: true })) { + add("page_evidence_manifest_path_invalid", "applicable page evidence requires a normalized JSON manifest path"); + } + return uniqueFindings(findings); +} + +export function validateConsumerPageIntentSemantics(value, recordPath) { + if (!isPlainObject(value) || value.page_evidence?.status !== "applicable") return []; + const findings = []; + const add = (code, message) => findings.push(finding(code, message, recordPath)); + const scenarios = Array.isArray(value.scenarios) ? value.scenarios.filter(isPlainObject) : []; + const browserIds = scenarios.filter((scenario) => scenario.evidence_method === "browser").map((scenario) => scenario.id); + if (browserIds.length === 0) { + add("page_evidence_runtime_scenario_required", "applicable page evidence requires at least one browser runtime scenario"); + return findings; + } + const mappingIds = new Set((Array.isArray(value.adoption_mappings) ? value.adoption_mappings : []) + .flatMap((mapping) => Array.isArray(mapping?.scenario_ids) ? mapping.scenario_ids : [])); + for (const scenarioId of browserIds) { + if (!mappingIds.has(scenarioId)) add("page_evidence_adoption_mapping_missing", `browser scenario ${scenarioId} requires an adoption mapping`); + } + return uniqueFindings(findings); +} + +export function validateConsumerPageEvidenceSemantics(value, manifest, recordPath) { + if (!isPlainObject(value) || !isPlainObject(manifest) || value.page_evidence?.status !== "applicable") return []; + const findings = []; + const add = (code, message) => findings.push(finding(code, message, recordPath)); + const scenarios = Array.isArray(value.scenarios) ? value.scenarios.filter(isPlainObject) : []; + const scenarioById = new Map(scenarios.map((scenario) => [scenario.id, scenario])); + const browserScenarios = scenarios.filter((scenario) => scenario.evidence_method === "browser"); + const browserIds = browserScenarios.map((scenario) => scenario.id); + const intendedIds = manifest.session?.intended_scenario_ids; + if (!sameStringSet(browserIds, intendedIds)) add("page_evidence_intent_scenario_mismatch", "page session intent must exactly match the conformance browser scenario catalog"); + + const sourcePaths = manifest.session?.source?.files?.map((entry) => entry.path); + if (!sameStringSet(value.consumer?.relevant_sources, sourcePaths)) add("page_evidence_source_set_mismatch", "conformance relevant sources must exactly match the page session source set"); + if (value.consumer?.repository !== manifest.repository || value.consumer?.repository !== manifest.session?.repository || value.consumer?.repository !== manifest.run?.repository) { + add("page_evidence_repository_mismatch", "conformance, page session, manifest, and run repositories must agree"); + } + if (value.consumer?.revision !== manifest.revision || value.consumer?.revision !== manifest.session?.revision || value.consumer?.revision !== manifest.run?.revision) { + add("page_evidence_revision_mismatch", "conformance, page session, manifest, and run revisions must agree"); + } + if (typeof manifest.review_by !== "string" || manifest.review_by.length === 0) add("page_evidence_review_by_required", "page evidence manifest requires review_by"); + + const pageScenarioById = new Map((Array.isArray(manifest.scenarios) ? manifest.scenarios : []).map((scenario) => [scenario.id, scenario])); + const manifestDirectory = value.page_evidence.manifest.split("/").slice(0, -1).join("/"); + for (const scenario of browserScenarios) { + if (scenario.source_digest !== manifest.session?.source?.sha256) add("page_evidence_source_mismatch", `browser scenario ${scenario.id} source digest must equal the page session source digest`); + if (scenario.session_id !== manifest.session?.session_id) add("page_evidence_session_mismatch", `browser scenario ${scenario.id} session identity must equal the page session`); + if (scenario.run_id !== manifest.run?.id) add("page_evidence_run_mismatch", `browser scenario ${scenario.id} run identity must equal the page run`); + const runnerPath = pageScenarioById.get(scenario.id)?.runner_result?.path; + if (scenario.result_artifact !== (runnerPath ? `${manifestDirectory}/${runnerPath}` : undefined)) add("page_evidence_result_artifact_mismatch", `browser scenario ${scenario.id} result artifact must equal its page runner result`); + } + + const dimensions = isPlainObject(value.migration_dimensions) ? Object.values(value.migration_dimensions) : []; + for (const scenarioId of dimensions.flatMap((dimension) => dimension?.status === "applicable" && Array.isArray(dimension.scenario_ids) ? dimension.scenario_ids : [])) { + if (scenarioById.get(scenarioId)?.evidence_method === "browser" && !intendedIds?.includes(scenarioId)) add("page_evidence_dimension_scenario_missing", `browser scenario ${scenarioId} used by an applicable dimension is absent from page intent`); + } + for (const scenarioId of (Array.isArray(value.adoption_mappings) ? value.adoption_mappings : []).flatMap((mapping) => Array.isArray(mapping?.scenario_ids) ? mapping.scenario_ids : [])) { + if (scenarioById.get(scenarioId)?.evidence_method === "browser" && !intendedIds?.includes(scenarioId)) add("page_evidence_adoption_scenario_missing", `browser scenario ${scenarioId} used by an adoption mapping is absent from page intent`); + } + return uniqueFindings(findings); +} diff --git a/scripts/consumer-evidence-governance-contract.mjs b/scripts/consumer-evidence-governance-contract.mjs new file mode 100644 index 0000000..148f824 --- /dev/null +++ b/scripts/consumer-evidence-governance-contract.mjs @@ -0,0 +1,210 @@ +import { immutableActionPins } from "./governance-policy-contract.mjs"; +import { workflowActionFailures } from "./workflow-action-contract.mjs"; + +const validatePath = ".github/workflows/validate.yml"; +const freshnessPath = ".github/workflows/evidence-freshness.yml"; + +function workflowJob(workflow, id) { + const marker = ` ${id}:\n`; + const start = workflow.indexOf(marker); + if (start === -1) return ""; + const remainder = workflow.slice(start + marker.length); + const next = remainder.search(/^ [a-zA-Z0-9_-]+:/m); + return marker + (next === -1 ? remainder : remainder.slice(0, next)); +} + +function missing(relative, content, fragments) { + return fragments.flatMap(([fragment, label = fragment]) => content.includes(fragment) ? [] : [`${relative}: missing ${label}`]); +} + +function missingCommands(relative, content, fragments) { + const commands = content.split("\n").map((line) => line.trim()).filter(Boolean); + return fragments.flatMap(([fragment, label = fragment]) => commands.some((command) => { + let candidate = command; + const assignment = /^[A-Za-z_][A-Za-z0-9_]*=(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s]+)?\s+/; + if (candidate.startsWith("env ")) candidate = candidate.slice(4); + while (true) { + const withoutUnset = candidate.replace(/^-u\s+[A-Za-z_][A-Za-z0-9_]*\s+/, ""); + if (withoutUnset !== candidate) { + candidate = withoutUnset; + continue; + } + const withoutAssignment = candidate.replace(assignment, ""); + if (withoutAssignment === candidate) break; + candidate = withoutAssignment; + } + if (candidate !== fragment && !candidate.startsWith(`${fragment} `)) return false; + const suffix = candidate.slice(fragment.length); + return !/(?:&&|\|\||[;&|])/.test(suffix); + }) ? [] : [`${relative}: missing ${label}`]); +} + +function hasIf(lines, indentation) { + const pattern = new RegExp(`^ {${indentation}}(?:-\\s+)?["']?if["']?:\\s*`); + return lines.some((line) => pattern.test(line)); +} + +function hasLiteralDisabledIf(lines, indentation) { + const pattern = new RegExp(`^ {${indentation}}(?:-\\s+)?["']?if["']?:\\s*(?:false|['"]false['"]|\\$\\{\\{\\s*false\\s*\\}\\})(?:\\s+#.*)?$`, "i"); + return lines.some((line) => pattern.test(line)); +} + +function executableRunText(workflow) { + const lines = workflow.split("\n"); + const commands = []; + const stepsLine = lines.findIndex((line) => /^\s*steps:\s*$/.test(line)); + if (stepsLine === -1) return ""; + const stepsIndent = lines[stepsLine].match(/^\s*/)[0].length; + for (let index = stepsLine + 1; index < lines.length;) { + const start = lines[index].match(new RegExp(`^ {${stepsIndent + 2}}-\\s+`)); + if (!start) { + if (lines[index].trim() && lines[index].match(/^\s*/)[0].length <= stepsIndent) break; + index += 1; + continue; + } + let end = index + 1; + while (end < lines.length && !new RegExp(`^ {${stepsIndent + 2}}-\\s+`).test(lines[end]) && (!lines[end].trim() || lines[end].match(/^\s*/)[0].length > stepsIndent)) end += 1; + const step = lines.slice(index, end); + if (!hasIf(step, stepsIndent + 2) && !hasIf(step, stepsIndent + 4)) { + const runIndex = step.findIndex((line) => new RegExp(`^(?: {${stepsIndent + 2}}-\\s+| {${stepsIndent + 4}})run:`).test(line)); + if (runIndex !== -1) { + const match = step[runIndex].match(/^\s*(?:-\s+)?run:\s*(.*)$/); + const scalar = match[1].replace(/\s+#.*$/, "").trim(); + if (scalar && !["|", ">", "|-", ">-"].includes(scalar)) commands.push(scalar); + if (["|", ">", "|-", ">-"].includes(scalar)) { + const blockCommands = []; + let heredoc; + for (const raw of step.slice(runIndex + 1)) { + const command = raw.trim(); + if (!command || command.startsWith("#")) continue; + if (heredoc) { + if (command === heredoc) heredoc = undefined; + continue; + } + const opener = command.match(/<<-?\s*['"]?([A-Za-z0-9_]+)['"]?/); + blockCommands.push(command.replace(/\s+#.*$/, "").trim()); + if (opener) heredoc = opener[1]; + } + if (scalar.startsWith(">")) commands.push(blockCommands.join(" ")); + else commands.push(...blockCommands); + } + } + } + index = end; + } + const logicalCommands = []; + let logical = ""; + for (const command of commands) { + logical = logical ? `${logical} ${command}` : command; + if (logical.endsWith("\\")) { + logical = logical.slice(0, -1).trimEnd(); + continue; + } + logicalCommands.push(logical); + logical = ""; + } + if (logical) logicalCommands.push(logical); + return logicalCommands.join("\n"); +} + +export function consumerEvidenceGovernanceFailures({ evidence, freshnessWorkflow, governance, validationWorkflow }) { + const failures = []; + failures.push(...missing("GOVERNANCE.md", governance, [ + ["scheduled_stale_audit: deferred"], + ["scheduled_evidence_audit: active_advisory"], + ["Decision: run a weekly advisory audit only for evidence records that already declare `expires_at` or `review_by`."], + ["No repository-wide maximum age or inferred time-to-live applies."], + ["Malformed records and auditor failures remain blocking even though due-date findings are advisory."], + [freshnessPath], + ["Monday at 05:00 UTC"], + ])); + failures.push(...missing("quality/evidence/executable-evidence.md", evidence, [ + ["Explicit evidence deadlines are audited weekly without inventing a repository-wide maximum age."], + ["Malformed records and auditor failures remain blocking even though due-date findings are advisory."], + ])); + const validationCommands = executableRunText(workflowJob(validationWorkflow, "validate")); + failures.push(...missingCommands(`${validatePath}: validate`, validationCommands, [ + ["node -c scripts/consumer-evidence-governance-contract.mjs"], + ["node -c scripts/json-schema-formats.mjs"], + ["node -c scripts/consumer-conformance-contract.mjs"], + ["node -c scripts/validate-consumer-conformance.mjs"], + ["node -c scripts/test-validate-consumer-conformance.mjs"], + ["node -c consumer-reference/fixtures/consumer-conformance/e2e-fixture.mjs"], + ["node -c scripts/page-evidence-contract.mjs"], + ["node -c scripts/page-evidence-fixture.mjs"], + ["node -c scripts/create-page-evidence-session.mjs"], + ["node -c scripts/finalize-page-evidence.mjs"], + ["node -c scripts/validate-page-evidence.mjs"], + ["node -c scripts/test-validate-page-evidence.mjs"], + ["node -c scripts/audit-evidence-freshness.mjs"], + ["node -c scripts/test-audit-evidence-freshness.mjs"], + ["node -c tests/fixtures/consumer-conformance-scenarios.mjs"], + ["node -c tests/helpers/render-consumer-conformance.mjs"], + ["node -c tests/consumer-conformance.spec.mjs"], + ["node -c scripts/test-consumer-conformance-sentinel.mjs"], + ["node scripts/test-validate-consumer-conformance.mjs --case valid-runtime-proof --json"], + ["node scripts/test-validate-consumer-conformance.mjs --json"], + ["node scripts/test-validate-page-evidence.mjs --json"], + ["node scripts/test-audit-evidence-freshness.mjs --json"], + [`node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/consumer-conformance-record.schema.json"))'`], + [`node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/page-evidence-session.schema.json"))'`], + [`node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/page-evidence-manifest.schema.json"))'`], + ])); + + const blocking = workflowJob(validationWorkflow, "consumer-conformance"); + if (!blocking) failures.push(`${validatePath}: missing consumer-conformance job`); + else { + if (hasLiteralDisabledIf(blocking.split("\n"), 4)) failures.push(`${validatePath}: consumer-conformance job must be enabled`); + if (hasIf(blocking.split("\n"), 4)) failures.push(`${validatePath}: consumer-conformance job must be unconditional`); + if (hasIf(blocking.split("\n"), 6) || hasIf(blocking.split("\n"), 8)) failures.push(`${validatePath}: consumer-conformance: required command steps must be unconditional`); + if (/^\s+continue-on-error:/m.test(blocking)) failures.push(`${validatePath}: consumer-conformance must be blocking`); + failures.push(...missing(`${validatePath}: consumer-conformance`, blocking, [ + ["mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a"], + ])); + failures.push(...missingCommands(`${validatePath}: consumer-conformance`, executableRunText(blocking), [ + ["npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line"], + ["node scripts/test-consumer-conformance-sentinel.mjs --json"], + ])); + } + const capture = workflowJob(validationWorkflow, "consumer-page-evidence"); + if (!capture) failures.push(`${validatePath}: missing consumer-page-evidence job`); + else { + if (!/^\s+continue-on-error:\s*true\s*$/m.test(capture)) failures.push(`${validatePath}: consumer-page-evidence must be nonblocking`); + failures.push(...missing(`${validatePath}: consumer-page-evidence`, capture, [ + ["CONSUMER_CONFORMANCE_CAPTURE_DIR: .tmp/consumer-page-evidence"], + ["actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02"], + ["retention-days: 14"], + ])); + failures.push(...missingCommands(`${validatePath}: consumer-page-evidence`, executableRunText(capture), [ + ["node scripts/create-page-evidence-session.mjs"], + ["npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line"], + ["node scripts/finalize-page-evidence.mjs"], + ["node scripts/validate-page-evidence.mjs"], + ])); + } + + failures.push(...missing(freshnessPath, freshnessWorkflow, [ + ["workflow_dispatch:"], + ["cron: '0 5 * * 1'", "Monday 05:00 UTC schedule"], + ["contents: read"], + ["persist-credentials: false"], + ["evidence-freshness-report.json", "JSON report"], + ["::warning file=", "GitHub warning emission"], + ["if: always()", "always-upload evidence"], + ["actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", "immutable upload action"], + ["retention-days: 14", "14-day retention"], + ])); + failures.push(...missing(freshnessPath, executableRunText(freshnessWorkflow), [ + ["scripts/audit-evidence-freshness.mjs", "freshness auditor"], + ["--record consumer-reference/baselines/calibration.json", "explicit calibration record inventory"], + ["--mode advisory"], + ])); + if (/continue-on-error\s*:/i.test(freshnessWorkflow)) failures.push(`${freshnessPath}: continue-on-error is forbidden because malformed records and tool failures must block`); + for (const pin of immutableActionPins) { + if ((pin.includes("actions/checkout") || pin.includes("actions/setup-node") || pin.includes("actions/upload-artifact")) && !freshnessWorkflow.includes(pin)) { + failures.push(`${freshnessPath}: missing immutable action pin ${pin}`); + } + } + failures.push(...workflowActionFailures(freshnessWorkflow, freshnessPath)); + return failures; +} diff --git a/scripts/consumer-reference-case-runner.mjs b/scripts/consumer-reference-case-runner.mjs new file mode 100644 index 0000000..eaac5d8 --- /dev/null +++ b/scripts/consumer-reference-case-runner.mjs @@ -0,0 +1,93 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { validateItemSchema } from "./consumer-reference-schema.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const validator = path.join(repositoryRoot, "scripts", "validate-consumer-reference.mjs"); +const itemPath = "consumer-reference/fixtures/item.json"; + +function findingCodes(output) { + if (!Array.isArray(output.failures)) return []; + return output.failures.flatMap((failure) => { + if (typeof failure === "string") return [failure]; + return typeof failure?.code === "string" ? [failure.code] : []; + }); +} + +function writeFixture(testCase, baseItem) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `stylegallery-consumer-reference-${testCase.name}-`)); + let externalRoot; + const item = Object.hasOwn(testCase, "value") ? structuredClone(testCase.value) : structuredClone(baseItem); + testCase.mutate?.(item); + const files = { + "CATALOG.md": "# Catalog\n", + [itemPath]: `${JSON.stringify(item, null, 2)}\n`, + "layout/index.md": testCase.layout ?? "# Layout\n", + "patterns/index.md": "# Patterns\n", + "quality/handoff.md": "Implementation handoff:\nConsumer reference: not_applicable\nConsumer reference reason: This fixture has no consumer-specific reference record.\n", + "scripts/pattern-data.mjs": testCase.patternData ?? "export const patterns = [];\n", + ...(testCase.extraFiles ?? {}), + }; + for (const [relative, content] of Object.entries(files)) { + const target = path.join(root, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + } + if (testCase.link) { + const link = path.join(root, "consumer-reference", "fixtures", "redirect.json"); + if (testCase.link === "inside") { + fs.symlinkSync(path.join(root, itemPath), link); + } else { + externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-consumer-reference-outside-")); + const external = path.join(externalRoot, "record.json"); + fs.writeFileSync(external, "{}\n"); + fs.symlinkSync(external, link); + } + } + if (testCase.itemLink) { + const itemTarget = path.join(root, itemPath); + fs.rmSync(itemTarget); + if (testCase.itemLink === "inside") { + const actual = path.join(root, "consumer-reference", "fixtures", "actual-item.json"); + fs.writeFileSync(actual, `${JSON.stringify(item, null, 2)}\n`); + fs.symlinkSync(actual, itemTarget); + } else { + externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-consumer-reference-item-outside-")); + const external = path.join(externalRoot, "item.json"); + fs.writeFileSync(external, `${JSON.stringify(item, null, 2)}\n`); + fs.symlinkSync(external, itemTarget); + } + } + return { externalRoot, item, root }; +} + +export function makeConsumerReferenceCaseRunner({ baseItem, schema }) { + return function runCase(testCase) { + const fixture = writeFixture(testCase, baseItem); + try { + const child = spawnSync(process.execPath, [validator, "--item", itemPath, "--json"], { + cwd: fixture.root, + encoding: "utf8", + }); + const output = JSON.parse(child.stdout); + const codes = findingCodes(output); + const accepted = child.status === 0 && output.ok && output.scaffold !== true; + const rejected = child.status !== 0 && !output.ok && codes.includes(testCase.expect); + const schemaCodes = validateItemSchema(fixture.item, schema).map((finding) => finding.code); + const schemaParity = testCase.schemaValid === undefined || (schemaCodes.length === 0) === testCase.schemaValid; + return { + actual: { codes, ok: output.ok, scaffold: output.scaffold === true, schemaCodes, status: child.status }, + expected: testCase.expect ?? "ok:true and exit:0", + name: testCase.name, + ok: (testCase.expect === null ? accepted : rejected) && schemaParity, + rules: testCase.rules ?? [], + }; + } finally { + fs.rmSync(fixture.root, { force: true, recursive: true }); + if (fixture.externalRoot) fs.rmSync(fixture.externalRoot, { force: true, recursive: true }); + } + }; +} diff --git a/scripts/consumer-reference-schema.mjs b/scripts/consumer-reference-schema.mjs index bd0e436..6816718 100644 --- a/scripts/consumer-reference-schema.mjs +++ b/scripts/consumer-reference-schema.mjs @@ -1,12 +1,19 @@ const fieldCodes = { artifact_mode: "artifact_mode_invalid", + default: "profile_default_forbidden", + example_only: "profile_example_only_required", fixture_independence: "fixture_independence_related", id: "item_id_invalid", + layout_source_sha: "profile_layout_source_sha_required", + local_foundations: "profile_foundations_path_required", maturity: "maturity_invalid", + profile_kind: "profile_kind_governed_local", + related_fixture_set_id: "profile_related_fixture_set_required", removal_trigger: "removal_trigger_invalid", replacement: "replacement_invalid", review_independence: "review_independence_single_account", schema_version: "schema_version_invalid", + tokens: "profile_tokens_path_required", }; export function isPlainObject(value) { @@ -29,6 +36,54 @@ function missingProperties(value, required) { return required.filter((key) => !Object.hasOwn(value, key)); } +function validateProfileObjects(item, schema, add) { + if (Object.hasOwn(item, "profile_kind")) { + const governed = schema.allOf.find((rule) => rule.if?.required?.includes("profile_kind"))?.then; + for (const property of governed?.required ?? []) if (!Object.hasOwn(item, property)) add("profile_field_required", `governed profile requires ${property}`); + for (const property of ["component_records", "evidence_records", "fixture_records", "generated_records", "state_records"]) { + if (!Object.hasOwn(item, property)) continue; + const value = item[property]; + const definition = schema.properties[property]; + const validItems = Array.isArray(value) && value.every((entry) => { + if (definition.items.const !== undefined) return entry === definition.items.const; + return definition.items.enum.includes(entry); + }); + if (!Array.isArray(value) || value.length < definition.minItems || new Set(value).size !== value.length || !validItems) add("profile_reference_array_invalid", `${property} does not satisfy the governed profile schema`); + } + } + if (Object.hasOwn(item, "environment_assumptions")) { + const environment = item.environment_assumptions; + const definition = schema.properties.environment_assumptions; + if (!isPlainObject(environment)) { + add("profile_environment_object_required", "environment_assumptions must be an object"); + } else { + for (const property of unknownProperties(environment, definition.properties)) add("profile_environment_property_unknown", `unknown environment assumption ${property}`); + if (!Object.hasOwn(environment, "user_agent_styles") || !scalarMatches(environment.user_agent_styles, definition.properties.user_agent_styles)) add("profile_ua_assumption_required", "user_agent_styles must be a non-empty string"); + const reset = environment.reset; + if (!isPlainObject(reset)) { + add("profile_reset_assumptions_required", "reset assumptions must be an object"); + } else { + for (const property of unknownProperties(reset, definition.properties.reset.properties)) add("profile_reset_property_unknown", `unknown reset assumption ${property}`); + for (const property of definition.properties.reset.required) { + if (!Object.hasOwn(reset, property) || !scalarMatches(reset[property], definition.properties.reset.properties[property])) add("profile_reset_assumptions_required", `reset requires ${property}`); + } + } + } + } + if (Object.hasOwn(item, "selection")) { + const selection = item.selection; + const definition = schema.properties.selection; + if (!isPlainObject(selection)) { + add("profile_selection_object_required", "selection must be an object"); + } else { + for (const property of unknownProperties(selection, definition.properties)) add("profile_selection_property_unknown", `unknown selection property ${property}`); + for (const property of definition.required) { + if (!Object.hasOwn(selection, property) || !scalarMatches(selection[property], definition.properties[property])) add("profile_explicit_selection_required", `selection requires ${property}`); + } + } + } +} + export function validateItemSchema(item, schema) { const findings = []; const add = (code, message) => findings.push({ code, message }); @@ -89,6 +144,8 @@ export function validateItemSchema(item, schema) { } } + validateProfileObjects(item, schema, add); + if (item.maturity === "stable" && item.support?.status === "ended") add("stable_support_ended", "stable maturity cannot have ended support"); if (item.maturity === "deprecated") { if (!Object.hasOwn(item, "replacement") || !Object.hasOwn(item, "removal_trigger")) add("deprecated_migration_required", "deprecated items require replacement and removal_trigger"); diff --git a/scripts/create-component-state-session.mjs b/scripts/create-component-state-session.mjs new file mode 100644 index 0000000..b221c81 --- /dev/null +++ b/scripts/create-component-state-session.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; +import { + canonicalIntended, + canonicalSourceManifest, + dirtyRelevantSources, + repositoryGitArgs, + sha256, +} from "./capture-session-contract.mjs"; +import { compileSchemas } from "./component-state-contract.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const options = { + json: false, + output: undefined, + profileRoot: path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local"), +}; +const failures = []; + +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") options.json = true; + else if (["--output", "--root"].includes(argument)) { + const value = process.argv[index + 1]; + if (!value) failures.push({ code: "argument_value_required", message: `${argument} requires a value`, path: "" }); + else { + if (argument === "--output") options.output = path.resolve(value); + if (argument === "--root") options.profileRoot = path.resolve(value); + index += 1; + } + } else failures.push({ code: "argument_unknown", message: `unsupported argument ${argument}`, path: "" }); +} + +if (!options.output) failures.push({ code: "argument_value_required", message: "--output is required", path: "" }); +else if (path.basename(options.output) !== "capture-session.json") failures.push({ code: "capture_session_path_invalid", message: "receipt filename must be capture-session.json", path: options.output }); +else if (fs.existsSync(options.output)) failures.push({ code: "capture_session_replay", message: "capture session receipt already exists and cannot be overwritten", path: options.output }); + +function git(...args) { + return execFileSync("git", repositoryGitArgs(repositoryRoot, ...args), { cwd: repositoryRoot, encoding: "utf8" }).trim(); +} + +const executable = chromium.executablePath(); +const revisionSegment = executable.split(path.sep).find((segment) => /^chromium(?:_headless_shell)?-\d+$/.test(segment)); +const packageJson = JSON.parse(fs.readFileSync(path.join(repositoryRoot, "package.json"), "utf8")); +const intended = canonicalIntended(options.profileRoot, failures); +const revision = git("rev-parse", "HEAD"); +if (process.env.GITHUB_SHA && process.env.GITHUB_SHA !== revision) failures.push({ code: "capture_revision_mismatch", message: `GITHUB_SHA ${process.env.GITHUB_SHA} does not match checked-out HEAD ${revision}`, path: repositoryRoot }); +let source; +try { + source = canonicalSourceManifest(repositoryRoot, options.profileRoot); + const dirty = dirtyRelevantSources(repositoryRoot, options.profileRoot); + if (dirty.length > 0) failures.push({ code: "capture_source_dirty", message: `relevant capture sources must be committed: ${dirty.join(", ")}`, path: repositoryRoot }); +} catch (error) { + failures.push({ code: "capture_source_unreadable", message: error instanceof Error ? error.message : String(error), path: options.profileRoot }); +} +const receipt = { + attempt: Number(process.env.GITHUB_RUN_ATTEMPT ?? 1), + branch: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || git("branch", "--show-current") || "detached", + environment: { + browser: execFileSync(executable, ["--version"], { encoding: "utf8" }).trim(), + browser_revision: revisionSegment ?? path.basename(executable), + container_image: process.env.SENTINEL_CONTAINER_IMAGE ?? `local-host:${os.release()}`, + kind: "browser", + lockfile_sha256: sha256(fs.readFileSync(path.join(repositoryRoot, "package-lock.json"))), + node: process.version, + platform: process.platform === "linux" && process.arch === "x64" ? "linux/amd64" : `${process.platform}/${process.arch}`, + playwright: packageJson.devDependencies["@playwright/test"], + viewport: "1024x768", + }, + intended, + nonce: crypto.randomBytes(32).toString("hex"), + record_kind: "component_state_capture_session", + repository: process.env.GITHUB_REPOSITORY ?? "changeroa/StyleGallery", + revision, + schema_version: "1.0", + session_id: crypto.randomUUID(), + source, + started_at: new Date().toISOString(), +}; + +const schemas = compileSchemas(path.join(repositoryRoot, "consumer-reference/schema")); +if (!schemas.capture(receipt)) { + for (const error of schemas.capture.errors ?? []) failures.push({ code: "capture_session_schema_invalid", message: `${error.instancePath || "/"} ${error.message}`, path: options.output ?? "" }); +} +if (failures.length === 0) { + fs.mkdirSync(path.dirname(options.output), { recursive: true }); + fs.writeFileSync(options.output, `${JSON.stringify(receipt, null, 2)}\n`); +} +const result = { failures, intendedArtifacts: intended.reduce((sum, profile) => sum + profile.scenarios.reduce((count, scenario) => count + scenario.channels.length, 0), 0), ok: failures.length === 0, receipt: options.output }; +if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +else if (result.ok) process.stdout.write(`created component-state capture session ${receipt.session_id}\n`); +else process.stderr.write(`${failures.map((failure) => `${failure.code}: ${failure.path}: ${failure.message}`).join("\n")}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/scripts/create-page-evidence-session.mjs b/scripts/create-page-evidence-session.mjs new file mode 100644 index 0000000..6695981 --- /dev/null +++ b/scripts/create-page-evidence-session.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + addSchemaFindings, + canonicalSourceManifest, + compilePageEvidenceSchemas, + dirtyRelevantSources, + finding, + gitIdentity, + metadata, + normalizeReference, + PAGE_EVIDENCE_RECEIPT, + readJsonFile, + resolveContained, +} from "./page-evidence-contract.mjs"; + +const styleGalleryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const options = { artifactRoot: undefined, json: false, record: undefined, root: undefined }; +const failures = []; + +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") options.json = true; + else if (["--artifact-root", "--record", "--root"].includes(argument)) { + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) failures.push(finding("argument_value_required", `${argument} requires a value`, "")); + else { + if (argument === "--artifact-root") options.artifactRoot = path.resolve(process.cwd(), value); + if (argument === "--record") options.record = value; + if (argument === "--root") options.root = path.resolve(process.cwd(), value); + index += 1; + } + } else failures.push(finding("argument_unknown", `unsupported argument ${argument}`, "")); +} + +for (const [name, value] of [["--root", options.root], ["--record", options.record], ["--artifact-root", options.artifactRoot]]) { + if (!value) failures.push(finding("argument_value_required", `${name} is required`, "")); +} + +let schemas; +try { schemas = compilePageEvidenceSchemas(path.join(styleGalleryRoot, "consumer-reference/schema")); } +catch (error) { failures.push(finding("page_evidence_schema_invalid", error instanceof Error ? error.message : String(error), "consumer-reference/schema")); } + +let artifactReference; +if (options.root && options.artifactRoot) { + artifactReference = normalizeReference(options.root, options.artifactRoot); + if (!artifactReference) failures.push(finding("page_evidence_artifact_escape", "artifact root must be inside the consumer repository", options.artifactRoot)); + else { + const resolved = resolveContained({ allowMissing: true, expectedType: "directory", prefix: "page_evidence_artifact", reference: artifactReference, root: options.root }, failures); + if (resolved && fs.existsSync(resolved.file) && fs.readdirSync(resolved.file).length > 0) failures.push(finding("page_evidence_session_replay", "artifact root must be empty before session start", artifactReference)); + } +} + +let record; +let recordReference; +if (options.root && options.record) { + recordReference = normalizeReference(options.root, options.record); + if (!recordReference) failures.push(finding("page_evidence_conformance_escape", "conformance record must be inside the consumer repository", options.record)); + else record = readJsonFile({ prefix: "page_evidence_conformance", reference: recordReference, root: options.root }, failures); +} + +const consumer = record?.value?.consumer; +const sourcePaths = consumer?.relevant_sources; +const browserScenarios = Array.isArray(record?.value?.scenarios) + ? record.value.scenarios.filter((scenario) => scenario?.evidence_method === "browser") + : []; +if (!consumer || typeof consumer.repository !== "string" || !/^[a-f0-9]{40}$/.test(consumer.revision ?? "") || !Array.isArray(sourcePaths)) { + failures.push(finding("page_evidence_conformance_invalid", "conformance record must declare consumer repository, revision, and relevant_sources", recordReference ?? "")); +} +if (browserScenarios.length === 0) failures.push(finding("page_evidence_intent_missing", "conformance record must declare at least one browser scenario", recordReference ?? "")); + +const scenarioIds = browserScenarios.map((scenario) => scenario.id); +const sessionIds = new Set(browserScenarios.map((scenario) => scenario.session_id)); +const runIds = new Set(browserScenarios.map((scenario) => scenario.run_id)); +if (new Set(scenarioIds).size !== scenarioIds.length || scenarioIds.some((id) => typeof id !== "string")) failures.push(finding("page_evidence_intent_invalid", "browser scenario IDs must be unique strings", recordReference ?? "")); +if (sessionIds.size !== 1 || runIds.size !== 1 || [...sessionIds, ...runIds].some((identity) => typeof identity !== "string" || identity.length === 0)) { + failures.push(finding("page_evidence_intent_identity_mismatch", "all page scenarios must use one nonempty session and run identity", recordReference ?? "")); +} + +const git = options.root ? gitIdentity(options.root, failures) : undefined; +if (git && consumer) { + if (git.revision !== consumer.revision || (process.env.GITHUB_SHA && process.env.GITHUB_SHA !== git.revision)) failures.push(finding("page_evidence_revision_mismatch", "conformance revision must equal checked-out Git HEAD", recordReference)); + if (git.repository && git.repository !== consumer.repository) failures.push(finding("page_evidence_repository_mismatch", "conformance repository differs from the Git remote identity", recordReference)); +} + +let source; +if (options.root && Array.isArray(sourcePaths)) { + source = canonicalSourceManifest(options.root, sourcePaths, failures); + if (source) { + const dirty = dirtyRelevantSources(options.root, sourcePaths); + if (dirty.length > 0) failures.push(finding("page_evidence_source_dirty", `relevant sources must be clean tracked files: ${dirty.join(", ")}`, options.root)); + if (browserScenarios.some((scenario) => scenario.source_digest !== source.sha256)) failures.push(finding("page_evidence_source_mismatch", "browser scenario source_digest must equal the relevant-source aggregate", recordReference)); + } +} + +const receipt = record && source && git && sessionIds.size === 1 && runIds.size === 1 ? { + attempt: Number(process.env.GITHUB_RUN_ATTEMPT ?? 1), + branch: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || git.branch, + conformance_record: metadata(record.bytes, "application/json", recordReference), + environment: { + kind: "browser_evidence", + node: process.version, + platform: `${process.platform}/${process.arch}`, + producer: "stylegallery_page_evidence", + }, + intended_scenario_ids: scenarioIds, + nonce: crypto.randomBytes(32).toString("hex"), + record_kind: "page_evidence_session", + repository: consumer.repository, + revision: consumer.revision, + run_id: [...runIds][0], + schema_version: "1.0", + session_id: [...sessionIds][0], + source, + started_at: new Date().toISOString(), +} : undefined; +if (receipt && schemas) addSchemaFindings(schemas.session, receipt, PAGE_EVIDENCE_RECEIPT, "page_evidence_session_schema_invalid", failures); + +const receiptFile = options.artifactRoot ? path.join(options.artifactRoot, PAGE_EVIDENCE_RECEIPT) : undefined; +if (failures.length === 0 && receiptFile) { + fs.mkdirSync(options.artifactRoot, { recursive: true }); + fs.writeFileSync(receiptFile, `${JSON.stringify(receipt, null, 2)}\n`, { flag: "wx" }); +} +const result = { + failures, + intendedScenarios: receipt?.intended_scenario_ids.length ?? 0, + ok: failures.length === 0, + receipt: receiptFile, + sessionId: receipt?.session_id, + sourceSha256: receipt?.source.sha256, +}; +if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +else if (result.ok) process.stdout.write(`created page evidence session ${result.sessionId}\n`); +else process.stderr.write(`${failures.map((issue) => `${issue.code}: ${issue.path}: ${issue.message}`).join("\n")}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/scripts/domain-document-boundaries.mjs b/scripts/domain-document-boundaries.mjs new file mode 100644 index 0000000..ccf1f3a --- /dev/null +++ b/scripts/domain-document-boundaries.mjs @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; +import { isOmoDependency, markdownLinkDestinations } from "./markdown-structure.mjs"; + +const generatedReference = /^design-engineering\/reference-profiles\/governed-local\/(?:editorial|terminal)\/generated\/(?:state-matrix|keyboard-matrix|evidence-coverage)\.md$/; +const ignoredDirectories = new Set([".git", ".omo", "node_modules"]); + +function walkMarkdown(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + if (ignoredDirectories.has(entry.name)) return []; + const target = path.join(dir, entry.name); + if (entry.isDirectory()) return walkMarkdown(target); + return entry.isFile() && entry.name.endsWith(".md") ? [target] : []; + }); +} + +function undeclaredDomainDocumentFailures(root, domains) { + return domains.flatMap((domain) => { + const declared = new Set([`${domain.slug}/index.md`, ...domain.leaves.map(([leaf]) => leaf), ...(domain.referenceDocuments ?? [])]); + return walkMarkdown(path.join(root, domain.slug)).flatMap((absolute) => { + const relative = path.relative(root, absolute); + if (generatedReference.test(relative) || declared.has(relative)) return []; + return [`${relative}: undeclared governed domain document`]; + }); + }); +} + +function omoDependencyFailures(root) { + return walkMarkdown(root).flatMap((absolute) => { + const relative = path.relative(root, absolute); + const content = fs.readFileSync(absolute, "utf8"); + if (markdownLinkDestinations(content).some(isOmoDependency)) return [`${relative}: tracked document must not depend on .omo`]; + return []; + }); +} + +export function collectDomainBoundaryFailures(root, domains) { + return [...undeclaredDomainDocumentFailures(root, domains), ...omoDependencyFailures(root)]; +} diff --git a/scripts/domain-validation-checks.mjs b/scripts/domain-validation-checks.mjs new file mode 100644 index 0000000..2a185a7 --- /dev/null +++ b/scripts/domain-validation-checks.mjs @@ -0,0 +1,141 @@ +import path from "node:path"; +import { isOmoDependency, markdownLinkDestinations, stripFencedCodeBlocks, structuralMarkdown } from "./markdown-structure.mjs"; + +export function createDomainValidationChecks({ + domains, + failures, + read, + referenceDocuments, + repository, + revision, + revisionPattern, + requiredLeafSections, + sourceOverrides, +}) { + function parseFrontmatter(relative, content) { + const match = content.match(/^---\n([\s\S]*?)\n---\n/); + if (!match) { + failures.push(`${relative}: missing frontmatter`); + return {}; + } + const metadata = {}; + for (const rawLine of match[1].split("\n")) { + if (!rawLine.trim()) continue; + const separator = rawLine.indexOf(":"); + if (separator === -1) { + failures.push(`${relative}: malformed frontmatter line`); + continue; + } + const key = rawLine.slice(0, separator).trim(); + const value = rawLine.slice(separator + 1).trim().replace(/^["']|["']$/g, ""); + if (Object.hasOwn(metadata, key)) failures.push(`${relative}: duplicate frontmatter key ${key}`); + metadata[key] = value; + } + return metadata; + } + + function checkIndex(domain) { + const relative = `${domain.slug}/index.md`; + const content = structuralMarkdown(read(relative)); + if (!content) return; + if (!content.includes("## Scope Boundary")) failures.push(`${relative}: missing Scope Boundary section`); + if (!/^In scope:\s*\S/m.test(content)) failures.push(`${relative}: missing In scope declaration`); + if (!/^Out of scope:\s*\S/m.test(content)) failures.push(`${relative}: missing Out of scope declaration`); + if (!/^Parent: \[[^\]]+\]\([^)]+\)/m.test(content)) failures.push(`${relative}: missing Parent navigation link`); + if (!/^Next: \[[^\]]+\]\([^)]+\)/m.test(content)) failures.push(`${relative}: missing Next navigation link`); + for (const leaf of domain.leaves) { + const target = path.posix.relative(domain.slug, leaf.path); + if (!content.match(new RegExp(`\\[[^\\]]+\\]\\(${target.replaceAll(".", "\\.")}\\)`))) { + failures.push(`${relative}: missing leaf route ${target}`); + } + } + if (domain.referenceDocuments?.length > 0 && !content.includes("[Reference Profiles](reference-profiles/index.md)")) { + failures.push(`${relative}: missing reference profile route`); + } + } + + function checkLeaf(domain, leaf, titles) { + const { path: relative, provenance, sourcePath: expectedSourcePath } = leaf; + const content = read(relative); + if (!content) return; + const metadata = parseFrontmatter(relative, content); + const requiredFields = ["type", "title", "description", "domain", "lifecycle"]; + if (provenance === "external") requiredFields.push("source_repository", "source_path", "source_revision"); + if (provenance === "local") requiredFields.push("provenance_kind"); + for (const field of requiredFields) { + if (!metadata[field]) failures.push(`${relative}: missing ${field}`); + } + const knownDomain = domains.some((candidate) => candidate.slug === metadata.domain); + if (metadata.domain && !knownDomain) failures.push(`${relative}: unknown domain ${metadata.domain}`); + else if (metadata.domain && metadata.domain !== domain.slug) failures.push(`${relative}: domain ${metadata.domain} does not match ${domain.slug}`); + if (metadata.lifecycle && metadata.lifecycle !== "experimental") { + const kind = provenance === "local" ? "local leaf" : provenance === "external" ? "external adaptation" : "domain leaf"; + failures.push(`${relative}: ${kind} lifecycle must be experimental`); + } + if (provenance === "external") { + const expectedSource = sourceOverrides[relative] ?? { repository, revision }; + if (metadata.source_repository && metadata.source_repository !== expectedSource.repository) failures.push(`${relative}: unexpected source_repository ${metadata.source_repository}`); + if (metadata.source_path && metadata.source_path !== expectedSourcePath) failures.push(`${relative}: unexpected source_path ${metadata.source_path}`); + if (metadata.source_revision && !revisionPattern.test(metadata.source_revision)) { + failures.push(`${relative}: source_revision must be a full 40-character lowercase Git SHA`); + } else if (metadata.source_revision && metadata.source_revision !== expectedSource.revision) { + failures.push(`${relative}: unexpected source_revision ${metadata.source_revision}`); + } + } else if (provenance === "local") { + if (metadata.provenance_kind && metadata.provenance_kind !== "local") failures.push(`${relative}: local leaf provenance_kind must be local`); + for (const field of ["source_repository", "source_path", "source_revision"]) { + if (Object.hasOwn(metadata, field)) failures.push(`${relative}: local leaf must not declare ${field}`); + } + } else { + for (const field of ["source_repository", "source_path", "source_revision"]) { + if (Object.hasOwn(metadata, field)) failures.push(`${relative}: locally authored leaf must omit ${field}`); + } + } + if (metadata.title) { + if (titles.has(metadata.title)) failures.push(`${relative}: duplicate title ${metadata.title}`); + titles.add(metadata.title); + } + const body = structuralMarkdown(content); + for (const section of requiredLeafSections) { + if (!body.includes(`## ${section}`)) failures.push(`${relative}: missing ${section} section`); + } + if (!/^Parent: \[[^\]]+\]\([^)]+\)/m.test(body)) failures.push(`${relative}: missing Parent navigation link`); + if (!/^Next: \[[^\]]+\]\([^)]+\)/m.test(body)) failures.push(`${relative}: missing Next navigation link`); + if (markdownLinkDestinations(content).some(isOmoDependency)) failures.push(`${relative}: tracked document must not depend on .omo`); + if (metadata.lifecycle === "experimental" && /canonical universal policy/i.test(body)) { + failures.push(`${relative}: experimental document claims canonical authority`); + } + } + + function checkReferenceDocuments() { + const index = stripFencedCodeBlocks(read(referenceDocuments[0])); + if (!/Domain classification:\s*design-engineering\./i.test(index)) { + failures.push(`${referenceDocuments[0]}: reference profiles must remain in the Design Engineering domain`); + } + if (!index.includes("[Governed Local Profiles](governed-local/index.md)")) { + failures.push(`${referenceDocuments[0]}: missing governed-local route`); + } + const external = stripFencedCodeBlocks(read(referenceDocuments[2])); + if (!external.includes("Synthetic validator coverage only") || !external.includes("no durable adopter record")) { + failures.push(`${referenceDocuments[1]}: external adaptation must remain documentation-only`); + } + } + + function checkPromotionBoundary() { + const relative = "DOMAINS.md"; + const content = stripFencedCodeBlocks(read(relative)); + const required = [ + "### Consumer Reference Promotion", + "applies only to consumer-local → shared-experimental invariant eligibility", + "Editorial and terminal are related examples in one fixture set", + "Shared stable has no numeric adoption threshold", + "Normative correctness may waive adoption count only", + "never silently relabeled experimental", + "Promotion records are JSON-only", + "zero adopter attestations", + ]; + for (const clause of required) if (!content.includes(clause)) failures.push(`${relative}: missing promotion boundary ${clause}`); + } + + return { checkIndex, checkLeaf, checkReferenceDocuments, checkPromotionBoundary }; +} diff --git a/scripts/evidence-artifact-contract.mjs b/scripts/evidence-artifact-contract.mjs new file mode 100644 index 0000000..fc84e45 --- /dev/null +++ b/scripts/evidence-artifact-contract.mjs @@ -0,0 +1,191 @@ +import fs from "node:fs"; +import path from "node:path"; +import { artifactMetadata } from "./artifact-metadata.mjs"; +import { sameJson, withinSession } from "./capture-session-contract.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; +import { visualExpectationFor } from "./visual-expectation-contract.mjs"; + +function finding(code, file, message) { + return { code, message, path: file }; +} + +function isInside(base, target) { + const relative = path.relative(base, target); + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +function isSafeArtifactPath(reference) { + return typeof reference === "string" + && reference.length > 0 + && !path.posix.isAbsolute(reference) + && !path.win32.isAbsolute(reference) + && !reference.includes("\\") + && !reference.includes("?") + && !reference.includes("#") + && !/^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) + && path.posix.normalize(reference) === reference + && !reference.split("/").some((segment) => segment === "." || segment === ".."); +} + +function validateIntegrity(pass, bytes, evidenceFile, failures) { + const actual = artifactMetadata(bytes, pass.artifact.media_type); + if (actual.sha256 !== pass.artifact.sha256 || actual.byte_length !== pass.artifact.byte_length) failures.push(finding("evidence_artifact_integrity", evidenceFile, `${pass.id} hash or byte length does not match`)); + if (pass.channel === "visual") { + if (!actual.width || !actual.height) failures.push(finding("evidence_png_invalid", evidenceFile, `${pass.id} is not a valid PNG with IHDR dimensions`)); + else if (actual.width !== pass.artifact.width || actual.height !== pass.artifact.height) failures.push(finding("evidence_png_dimensions_mismatch", evidenceFile, `${pass.id} PNG dimensions do not match`)); + } + return actual; +} + +function observedDomValue(document, key) { + if (key === "active") return String(document.active); + if (key === "role") return document.role; + return document.attributes?.[key]; +} + +function validateDom(document, pass, scenario, fixture, profileId, evidenceFile, failures) { + if (document.schema_version !== "1.0" || document.channel !== "dom" || document.profile_id !== profileId || document.scenario_id !== pass.scenario_id || document.semantic_mode !== scenario.semantic_mode) { + failures.push(finding("evidence_dom_identity_mismatch", evidenceFile, `${pass.id} DOM identity does not match its pass`)); + } + if (document.activation_key !== fixture.activation_key) failures.push(finding("evidence_runtime_identity_mismatch", evidenceFile, `${pass.id} activation key differs from the canonical fixture`)); + for (const [key, expected] of Object.entries(scenario.expected.dom)) if (observedDomValue(document, key) !== expected) failures.push(finding("evidence_dom_content_mismatch", evidenceFile, `${pass.id} DOM ${key} differs from canonical state`)); + const visual = JSON.stringify([...(document.visual_states ?? [])].sort()); + if (visual !== JSON.stringify([...scenario.expected.visual].sort())) failures.push(finding("evidence_dom_content_mismatch", evidenceFile, `${pass.id} visual states differ from canonical state`)); + const expectedActivations = scenario.expected.activation === "allowed" ? 1 : 0; + if (document.activation_count !== expectedActivations) failures.push(finding("evidence_dom_content_mismatch", evidenceFile, `${pass.id} activation result differs from canonical state`)); +} + +function validateAx(document, pass, scenario, fixture, profileId, evidenceFile, failures) { + if (document.schema_version !== "1.0" || document.channel !== "ax" || document.profile_id !== profileId || document.scenario_id !== pass.scenario_id || document.semantic_mode !== scenario.semantic_mode || document.name !== fixture.label) { + failures.push(finding("evidence_ax_identity_mismatch", evidenceFile, `${pass.id} AX identity does not match its pass`)); + } + if (document.role !== scenario.expected.ax.role) failures.push(finding("evidence_ax_content_mismatch", evidenceFile, `${pass.id} AX role differs from canonical state`)); + for (const [key, expected] of Object.entries(scenario.expected.ax)) { + const actual = key === "role" ? document.role : document.properties?.[key]; + if (actual !== expected) failures.push(finding("evidence_ax_content_mismatch", evidenceFile, `${pass.id} AX ${key} differs from canonical state`)); + } +} + +function validateVisual(document, pass, scenario, profileId, actual, visualEnvironments, evidenceFile, failures) { + if (document.schema_version !== "1.0" || document.channel !== "visual" || document.profile_id !== profileId || document.scenario_id !== pass.scenario_id || document.semantic_mode !== scenario.semantic_mode) { + failures.push(finding("evidence_visual_identity_mismatch", evidenceFile, `${pass.id} visual identity does not match its pass`)); + } + if (document.source_sha256 !== pass.session?.source?.sha256) failures.push(finding("capture_source_mismatch", evidenceFile, `${pass.id} visual source digest differs from its capture session`)); + const expectedImage = { path: path.posix.basename(pass.artifact.path), ...actual }; + if (!sameJson(document.image, expectedImage)) failures.push(finding("evidence_visual_image_mismatch", evidenceFile, `${pass.id} visual metadata does not match its PNG`)); + try { + const expectation = visualExpectationFor(scenario, pass.environment, visualEnvironments); + if (actual.sha256 !== expectation.sha256 || actual.width !== expectation.width || actual.height !== expectation.height) failures.push(finding("evidence_visual_expectation_mismatch", evidenceFile, `${pass.id} PNG differs from its pre-capture approved visual expectation`)); + } catch (error) { + failures.push(finding("evidence_visual_expectation_invalid", evidenceFile, error instanceof Error ? error.message : String(error))); + } +} + +function parseArtifactJson(bytes, pass, scenario, fixture, profileId, evidenceFile, failures, schemas, session) { + let document; + try { + document = parseStrictJson(bytes.toString("utf8")); + } catch (error) { + failures.push(finding("evidence_json_invalid", evidenceFile, `${pass.id} is not structured JSON: ${error instanceof Error ? error.message : String(error)}`)); + return undefined; + } + const validate = pass.channel === "dom" ? schemas.dom : pass.channel === "ax" ? schemas.ax : undefined; + if (validate && !validate(document)) { + const code = pass.channel === "dom" ? "evidence_dom_schema_invalid" : "evidence_ax_schema_invalid"; + for (const error of validate.errors ?? []) failures.push(finding(code, evidenceFile, `${pass.id} ${error.instancePath || "/"} ${error.message}`)); + return undefined; + } + if (!sameJson(document.capture_session, pass.session)) failures.push(finding("capture_session_mismatch", evidenceFile, `${pass.id} artifact session differs from its pass`)); + if (document.captured_at !== pass.recorded_at) failures.push(finding("evidence_recorded_at_mismatch", evidenceFile, `${pass.id} recorded_at is not derived from artifact content`)); + if (session && !withinSession(document.captured_at, session.started_at, session.completed_at)) failures.push(finding("capture_session_time_outside", evidenceFile, `${pass.id} was not captured within its session`)); + if (pass.channel === "dom") validateDom(document, pass, scenario, fixture, profileId, evidenceFile, failures); + else if (pass.channel === "ax") validateAx(document, pass, scenario, fixture, profileId, evidenceFile, failures); + else failures.push(finding("evidence_channel_content_unknown", evidenceFile, `${pass.id} has no structured content contract`)); + return document; +} + +function readArtifact(pass, artifactRoot, evidenceFile, failures) { + const reference = pass.artifact?.path; + if (!isSafeArtifactPath(reference)) { + failures.push(finding("evidence_artifact_escape", evidenceFile, `${pass.id} artifact path is unsafe`)); + return undefined; + } + const root = path.resolve(artifactRoot); + const target = path.resolve(root, reference); + if (!isInside(root, target) || !fs.existsSync(target)) { + failures.push(finding("evidence_artifact_missing", evidenceFile, `${pass.id} artifact is missing`)); + return undefined; + } + const metadata = fs.lstatSync(target); + if (metadata.isSymbolicLink()) { + failures.push(finding("evidence_artifact_symlink", evidenceFile, `${pass.id} artifact must not be a symlink`)); + return undefined; + } + if (!metadata.isFile()) { + failures.push(finding("evidence_artifact_type_invalid", evidenceFile, `${pass.id} artifact must be a regular file`)); + return undefined; + } + const realRoot = fs.realpathSync(root); + const realTarget = fs.realpathSync(target); + if (!isInside(realRoot, realTarget) || realTarget !== path.join(realRoot, reference)) { + failures.push(finding("evidence_artifact_redirect", evidenceFile, `${pass.id} artifact resolves through a redirect`)); + return undefined; + } + return fs.readFileSync(realTarget); +} + +function parseVisualMetadata(pass, artifactRoot, scenario, profileId, actual, visualEnvironments, evidenceFile, failures, schemas, session) { + const reference = pass.artifact.path.replace(/\.png$/, ".visual.json"); + const bytes = readArtifact({ artifact: { path: reference }, id: `${pass.id}-metadata` }, artifactRoot, evidenceFile, failures); + if (!bytes) return undefined; + let document; + try { + document = parseStrictJson(bytes.toString("utf8")); + } catch (error) { + failures.push(finding("evidence_visual_json_invalid", evidenceFile, `${pass.id} visual metadata is not structured JSON: ${error instanceof Error ? error.message : String(error)}`)); + return undefined; + } + if (!schemas.visual(document)) { + for (const error of schemas.visual.errors ?? []) failures.push(finding("evidence_visual_schema_invalid", evidenceFile, `${pass.id} ${error.instancePath || "/"} ${error.message}`)); + return undefined; + } + if (!sameJson(document.capture_session, pass.session)) failures.push(finding("capture_session_mismatch", evidenceFile, `${pass.id} visual metadata session differs from its pass`)); + if (document.captured_at !== pass.recorded_at) failures.push(finding("evidence_recorded_at_mismatch", evidenceFile, `${pass.id} recorded_at is not derived from visual metadata`)); + if (session && !withinSession(document.captured_at, session.started_at, session.completed_at)) failures.push(finding("capture_session_time_outside", evidenceFile, `${pass.id} visual metadata was not captured within its session`)); + validateVisual(document, pass, scenario, profileId, actual, visualEnvironments, evidenceFile, failures); + return document; +} + +export function validateEvidenceArtifacts({ artifactRoot, evidence, evidenceFile, fixture, profileId, schemas, session, states }) { + const failures = []; + const scenarios = new Map(states.scenarios.map((scenario) => [scenario.id, scenario])); + const fixtures = new Map(fixture.scenarios.map((scenario) => [scenario.id, scenario])); + const capturedTimes = new Map(); + for (const pass of evidence.passes ?? []) { + const scenario = scenarios.get(pass.scenario_id); + const runtimeFixture = fixtures.get(pass.scenario_id); + if (!scenario || !runtimeFixture || !pass.artifact?.media_type) continue; + const bytes = readArtifact(pass, artifactRoot, evidenceFile, failures); + if (!bytes) continue; + const actual = validateIntegrity(pass, bytes, evidenceFile, failures); + if (pass.artifact.media_type === "application/json") { + const document = parseArtifactJson(bytes, pass, scenario, runtimeFixture, profileId, evidenceFile, failures, schemas, session); + if (document?.captured_at) { + if (!capturedTimes.has(pass.scenario_id)) capturedTimes.set(pass.scenario_id, new Set()); + capturedTimes.get(pass.scenario_id).add(document.captured_at); + } + } else if (pass.channel === "visual") { + const document = parseVisualMetadata(pass, artifactRoot, scenario, profileId, actual, states.visual_environments, evidenceFile, failures, schemas, session); + if (document?.captured_at) { + if (!capturedTimes.has(pass.scenario_id)) capturedTimes.set(pass.scenario_id, new Set()); + capturedTimes.get(pass.scenario_id).add(document.captured_at); + } + } + } + for (const [scenarioId, times] of capturedTimes) { + if (times.size !== 1) failures.push(finding("capture_session_time_mismatch", evidenceFile, `${scenarioId} visual, DOM, and AX captured_at values differ`)); + const capturedAt = [...times][0]; + for (const pass of evidence.passes.filter((entry) => entry.scenario_id === scenarioId)) if (pass.recorded_at !== capturedAt) failures.push(finding("evidence_recorded_at_mismatch", evidenceFile, `${pass.id} recorded_at is not derived from its scenario artifacts`)); + } + return failures; +} diff --git a/scripts/finalize-component-state-evidence.mjs b/scripts/finalize-component-state-evidence.mjs new file mode 100644 index 0000000..9e05651 --- /dev/null +++ b/scripts/finalize-component-state-evidence.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { artifactMetadata } from "./artifact-metadata.mjs"; +import { + canonicalIntended, + readCaptureSession, + sameJson, + sourceManifestMatches, +} from "./capture-session-contract.mjs"; +import { compileSchemas, validateEvidenceArtifacts } from "./component-state-contract.mjs"; +import { resolveProfileRecords } from "./profile-record-contract.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const options = { + artifactRoot: undefined, + json: false, + output: undefined, + profileRoot: path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local"), + sessionReceipt: undefined, + writeCanonical: false, +}; +const failures = []; + +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") options.json = true; + else if (argument === "--write-canonical") options.writeCanonical = true; + else if (["--artifact-root", "--output", "--root", "--session-receipt"].includes(argument)) { + const value = process.argv[index + 1]; + if (!value) failures.push({ code: "argument_value_required", message: `${argument} requires a value`, path: "" }); + else { + if (argument === "--artifact-root") options.artifactRoot = path.resolve(value); + if (argument === "--output") options.output = path.resolve(value); + if (argument === "--root") options.profileRoot = path.resolve(value); + if (argument === "--session-receipt") options.sessionReceipt = path.resolve(value); + index += 1; + } + } else failures.push({ code: "argument_unknown", message: `unsupported argument ${argument}`, path: "" }); +} + +if (!options.artifactRoot) failures.push({ code: "argument_value_required", message: "--artifact-root is required", path: "" }); +const output = options.output ?? (options.artifactRoot ? path.join(options.artifactRoot, "runtime-manifest.json") : undefined); +if (output && fs.existsSync(output)) failures.push({ code: "capture_session_replay", message: "completed session manifest already exists and cannot be overwritten", path: output }); +const receiptFile = options.sessionReceipt ?? (options.artifactRoot ? path.join(options.artifactRoot, "capture-session.json") : ""); +if (options.artifactRoot && receiptFile !== path.join(options.artifactRoot, "capture-session.json")) failures.push({ code: "capture_session_path_invalid", message: "session receipt must be the artifact-root capture-session.json", path: receiptFile }); +const schemas = compileSchemas(path.join(repositoryRoot, "consumer-reference/schema")); +const capture = receiptFile ? readCaptureSession(receiptFile, schemas.capture, failures) : undefined; +const intended = canonicalIntended(options.profileRoot, failures); +if (capture && !sameJson(capture.receipt.intended, intended)) failures.push({ code: "capture_session_intent_mismatch", message: "receipt intent differs from canonical profile scenarios", path: receiptFile }); +if (capture && !capture.receipt.source) failures.push({ code: "capture_source_missing", message: "capture receipt must include its canonical source manifest", path: receiptFile }); +else if (capture && !sourceManifestMatches(capture.receipt.source, repositoryRoot, options.profileRoot)) failures.push({ code: "capture_source_drift", message: "capture sources differ from the receipt source manifest", path: receiptFile }); + +const completedAt = new Date().toISOString(); +const completedSession = capture ? { + attempt: capture.receipt.attempt, + branch: capture.receipt.branch, + completed_at: completedAt, + environment: capture.receipt.environment, + intended: capture.receipt.intended, + nonce: capture.receipt.nonce, + receipt: capture.artifact, + receipt_sha256: capture.digest, + repository: capture.receipt.repository, + revision: capture.receipt.revision, + session_id: capture.receipt.session_id, + source: capture.receipt.source, + started_at: capture.receipt.started_at, +} : undefined; +const run = capture ? { + attempt: capture.receipt.attempt, + id: capture.receipt.session_id, + repository: capture.receipt.repository, + revision: capture.receipt.revision, + source: process.env.GITHUB_ACTIONS === "true" ? "github_actions" : "local", +} : undefined; +const channelFiles = Object.freeze({ ax: "ax.json", dom: "dom.json", visual: "png" }); +const records = []; +const expectedFiles = new Set(); +const canonicalWrites = []; + +for (const profileIntent of intended) { + const profileRoot = path.join(options.profileRoot, profileIntent.profile_name); + const profileFailures = []; + const resolved = resolveProfileRecords(profileRoot, profileFailures); + failures.push(...profileFailures); + const fixture = resolved?.records.fixture[0]?.value; + const states = resolved?.records.states[0]?.value; + if (!resolved || !fixture || !states || !capture || !run || !completedSession) continue; + const stateById = new Map(states.scenarios.map((scenario) => [scenario.id, scenario])); + const passes = []; + for (const scenario of fixture.scenarios) { + const canonical = stateById.get(scenario.id); + const documents = {}; + for (const channel of ["dom", "ax", "visual"]) { + const file = path.join(options.artifactRoot, "runtime", `${profileIntent.profile_name}-${scenario.id}.${channel}.json`); + try { documents[channel] = parseStrictJson(fs.readFileSync(file, "utf8")); } + catch (error) { failures.push({ code: "evidence_json_invalid", message: error instanceof Error ? error.message : String(error), path: file }); } + if (channel === "visual") expectedFiles.add(path.basename(file)); + } + const capturedTimes = new Set(Object.values(documents).map((document) => document?.captured_at).filter(Boolean)); + if (capturedTimes.size !== 1) failures.push({ code: "capture_session_time_mismatch", message: `${scenario.id} visual, DOM, and AX must share one captured_at`, path: profileRoot }); + const recordedAt = [...capturedTimes][0] ?? ""; + for (const channel of scenario.required_channels) { + const suffix = channelFiles[channel]; + const artifactPath = `runtime/${profileIntent.profile_name}-${scenario.id}.${suffix}`; + expectedFiles.add(path.basename(artifactPath)); + const absolute = path.join(options.artifactRoot, artifactPath); + if (!fs.existsSync(absolute) || !fs.lstatSync(absolute).isFile() || fs.lstatSync(absolute).isSymbolicLink()) { + failures.push({ code: "runtime_artifact_missing", message: `${artifactPath} is missing or not a regular file`, path: absolute }); + continue; + } + const mediaType = channel === "visual" ? "image/png" : "application/json"; + passes.push({ + artifact: { path: artifactPath, ...artifactMetadata(fs.readFileSync(absolute), mediaType) }, + channel, + environment: capture.receipt.environment, + id: `${scenario.id}-${channel}`, + recorded_at: recordedAt, + result: { observed: `Recorded ${channel} evidence for ${scenario.id}; this is not certification.`, status: "passed" }, + run, + scenario_id: scenario.id, + session: capture.link, + scope: { component: "button", semantic_mode: scenario.semantic_mode, state_set: canonical.states }, + }); + } + } + const evidence = { + claim_boundary: "Scenario artifacts are evidence, not visual regression baselines or accessibility certification.", + component_id: "button", + passes, + profile_id: resolved.profile.id, + schema_version: "1.0", + }; + failures.push(...validateEvidenceArtifacts(profileRoot, options.artifactRoot, evidence, schemas, completedSession)); + records.push(evidence); + if (resolved.records.evidence[0]) canonicalWrites.push([resolved.records.evidence[0].path, evidence]); +} + +const runtimeRoot = options.artifactRoot ? path.join(options.artifactRoot, "runtime") : ""; +if (runtimeRoot && fs.existsSync(runtimeRoot)) { + for (const entry of fs.readdirSync(runtimeRoot, { withFileTypes: true })) if (!entry.isFile() || !expectedFiles.has(entry.name)) failures.push({ code: "runtime_artifact_unmanifested", message: `${entry.name} is not part of the closed artifact set`, path: path.join(runtimeRoot, entry.name) }); +} + +const manifest = { + claim_boundary: "Scenario artifacts are evidence, not visual regression baselines or accessibility certification.", + environment: capture?.receipt.environment, + recorded_at: completedAt, + record_kind: "component_state_runtime_manifest", + records, + run, + schema_version: "1.0", + session: completedSession, +}; +if (!schemas.runtime(manifest)) for (const error of schemas.runtime.errors ?? []) failures.push({ code: "runtime_manifest_schema_invalid", message: `${error.instancePath || "/"} ${error.message}`, path: options.output ?? "" }); +if (failures.length === 0 && options.writeCanonical) for (const [file, evidence] of canonicalWrites) fs.writeFileSync(file, `${JSON.stringify(evidence, null, 2)}\n`); +if (failures.length === 0 && output) fs.writeFileSync(output, `${JSON.stringify(manifest, null, 2)}\n`); +const result = { artifactCount: expectedFiles.size, failures, manifest: output, ok: failures.length === 0, profileCount: records.length, sessionId: capture?.receipt.session_id }; +if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +else if (result.ok) process.stdout.write(`finalized ${result.artifactCount} component state artifacts\n`); +else process.stderr.write(`${failures.map((failure) => `${failure.code}: ${failure.path}: ${failure.message}`).join("\n")}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/scripts/finalize-page-evidence.mjs b/scripts/finalize-page-evidence.mjs new file mode 100644 index 0000000..678c3d4 --- /dev/null +++ b/scripts/finalize-page-evidence.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + addSchemaFindings, + canonicalSourceManifest, + compilePageEvidenceSchemas, + dirtyRelevantSources, + finding, + gitIdentity, + listArtifactFiles, + metadata, + normalizeReference, + PAGE_EVIDENCE_CLAIM, + PAGE_EVIDENCE_MANIFEST, + PAGE_EVIDENCE_RECEIPT, + readJsonFile, + readContainedBytes, + readReceipt, + resolveContained, + sameJson, + sameStringSet, + withinSession, +} from "./page-evidence-contract.mjs"; + +const styleGalleryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const options = { artifactRoot: undefined, json: false, priorManifest: undefined, reviewBy: undefined, root: undefined, runnerResults: [] }; +const failures = []; + +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") options.json = true; + else if (["--artifact-root", "--prior-manifest", "--review-by", "--root", "--runner-result"].includes(argument)) { + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) failures.push(finding("argument_value_required", `${argument} requires a value`, "")); + else { + if (argument === "--artifact-root") options.artifactRoot = path.resolve(process.cwd(), value); + if (argument === "--prior-manifest") options.priorManifest = value; + if (argument === "--review-by") options.reviewBy = value; + if (argument === "--root") options.root = path.resolve(process.cwd(), value); + if (argument === "--runner-result") options.runnerResults.push(value); + index += 1; + } + } else failures.push(finding("argument_unknown", `unsupported argument ${argument}`, "")); +} +for (const [name, value] of [["--root", options.root], ["--artifact-root", options.artifactRoot], ["--review-by", options.reviewBy]]) { + if (!value) failures.push(finding("argument_value_required", `${name} is required`, "")); +} +if (options.runnerResults.length === 0) failures.push(finding("argument_value_required", "at least one --runner-result is required", "")); + +let schemas; +try { schemas = compilePageEvidenceSchemas(path.join(styleGalleryRoot, "consumer-reference/schema")); } +catch (error) { failures.push(finding("page_evidence_schema_invalid", error instanceof Error ? error.message : String(error), "consumer-reference/schema")); } + +let artifactReference; +let artifactFiles = []; +if (options.root && options.artifactRoot) { + artifactReference = normalizeReference(options.root, options.artifactRoot); + if (!artifactReference) failures.push(finding("page_evidence_artifact_escape", "artifact root must be inside the consumer repository", options.artifactRoot)); + else if (fs.existsSync(options.artifactRoot) && fs.lstatSync(options.artifactRoot).isSymbolicLink()) failures.push(finding("page_evidence_artifact_redirect", "artifact root must not be a filesystem redirect", artifactReference)); + else { + const resolved = resolveContained({ expectedType: "directory", prefix: "page_evidence_artifact", reference: artifactReference, root: options.root }, failures); + if (resolved) artifactFiles = listArtifactFiles(options.artifactRoot, failures); + } +} +const packetWithinLimits = !failures.some((entry) => entry.code === "page_evidence_artifact_limit"); +const manifestFile = options.artifactRoot ? path.join(options.artifactRoot, PAGE_EVIDENCE_MANIFEST) : undefined; +if (manifestFile && fs.existsSync(manifestFile)) failures.push(finding("page_evidence_session_replay", "completed manifest already exists and cannot be overwritten", PAGE_EVIDENCE_MANIFEST)); +const receiptRecord = options.artifactRoot && schemas && packetWithinLimits ? readReceipt(options.artifactRoot, schemas, failures) : undefined; +const receipt = receiptRecord?.valid ? receiptRecord : undefined; + +const git = options.root ? gitIdentity(options.root, failures) : undefined; +if (receipt && git) { + if (git.revision !== receipt.value.revision) failures.push(finding("page_evidence_revision_mismatch", "checked-out revision differs from the start receipt", PAGE_EVIDENCE_RECEIPT)); + if (git.repository && git.repository !== receipt.value.repository) failures.push(finding("page_evidence_repository_mismatch", "Git remote identity differs from the start receipt", PAGE_EVIDENCE_RECEIPT)); +} + +if (receipt && options.root) { + const sourcePaths = receipt.value.source.files.map((entry) => entry.path); + const currentSource = canonicalSourceManifest(options.root, sourcePaths, failures); + if (currentSource && !sameJson(currentSource, receipt.value.source)) failures.push(finding("page_evidence_source_drift", "relevant source bytes differ from the start receipt", PAGE_EVIDENCE_RECEIPT)); + if (dirtyRelevantSources(options.root, sourcePaths).length > 0) failures.push(finding("page_evidence_source_drift", "relevant sources became dirty after session start", PAGE_EVIDENCE_RECEIPT)); + const conformance = readJsonFile({ prefix: "page_evidence_conformance", reference: receipt.value.conformance_record.path, root: options.root }, failures); + if (conformance && !sameJson(metadata(conformance.bytes, "application/json", conformance.reference), receipt.value.conformance_record)) failures.push(finding("page_evidence_conformance_drift", "conformance record differs from the start receipt", conformance.reference)); +} + +let prior; +if (options.priorManifest && options.root && schemas) { + const reference = normalizeReference(options.root, options.priorManifest); + if (!reference) failures.push(finding("page_evidence_reuse_manifest_escape", "prior manifest must be inside the consumer repository", options.priorManifest)); + else { + prior = readJsonFile({ prefix: "page_evidence_reuse_manifest", reference, root: options.root }, failures); + if (prior) { + prior.sha256 = metadata(prior.bytes, "application/json", reference).sha256; + prior.valid = addSchemaFindings(schemas.manifest, prior.value, reference, "page_evidence_reuse_manifest_invalid", failures); + } + } +} + +const runnerReferences = options.artifactRoot ? options.runnerResults.map((candidate) => normalizeReference(options.artifactRoot, candidate)) : []; +if (runnerReferences.some((reference) => !reference) || new Set(runnerReferences).size !== runnerReferences.length) failures.push(finding("page_evidence_runner_path_invalid", "runner results must be unique normalized paths inside artifact root", "")); +const completedAt = new Date().toISOString(); +const scenarios = []; +const controlFiles = new Set([PAGE_EVIDENCE_MANIFEST, PAGE_EVIDENCE_RECEIPT, ...runnerReferences.filter(Boolean)]); +const expectedFiles = new Set([...controlFiles].filter((reference) => reference !== PAGE_EVIDENCE_MANIFEST)); +const artifactOwners = new Map(); +let canonicalRun; + +for (const reference of packetWithinLimits ? runnerReferences.filter(Boolean) : []) { + const runner = readJsonFile({ prefix: "page_evidence_runner", reference, root: options.artifactRoot }, failures); + if (!runner || !schemas) continue; + if (!addSchemaFindings(schemas.runner, runner.value, reference, "page_evidence_runner_schema_invalid", failures)) continue; + const value = runner.value; + if (value.status !== "passed") failures.push(finding("page_evidence_runner_failed", `${value.scenario_id ?? reference} runner did not pass`, reference)); + if (receipt) { + if (value.session_id !== receipt.value.session_id || value.nonce !== receipt.value.nonce || value.receipt_sha256 !== receipt.digest) failures.push(finding("page_evidence_session_mismatch", `${value.scenario_id ?? reference} runner session differs from the receipt`, reference)); + if (value.revision !== receipt.value.revision || value.run?.revision !== receipt.value.revision) failures.push(finding("page_evidence_revision_mismatch", `${value.scenario_id ?? reference} runner revision differs from the receipt`, reference)); + if (value.repository !== receipt.value.repository || value.run?.repository !== receipt.value.repository) failures.push(finding("page_evidence_repository_mismatch", `${value.scenario_id ?? reference} runner repository differs from the receipt`, reference)); + if (value.source_sha256 !== receipt.value.source.sha256) failures.push(finding("page_evidence_source_mismatch", `${value.scenario_id ?? reference} runner source differs from the receipt`, reference)); + if (value.run?.id !== receipt.value.run_id || value.run?.attempt !== receipt.value.attempt) failures.push(finding("page_evidence_run_mismatch", `${value.scenario_id ?? reference} runner identity differs from the receipt`, reference)); + if (!receipt.value.intended_scenario_ids.includes(value.scenario_id)) failures.push(finding("page_evidence_scenario_unintended", `${value.scenario_id ?? reference} was not declared at session start`, reference)); + if (!withinSession(value.recorded_at, receipt.value.started_at, completedAt)) failures.push(finding("page_evidence_capture_outside_session", `${value.scenario_id ?? reference} was recorded outside the session interval`, reference)); + } + const run = value.run ? { ...value.run, status: "passed" } : undefined; + if (canonicalRun && !sameJson(canonicalRun, run)) failures.push(finding("page_evidence_run_mismatch", "runner results mix run identities", reference)); + else if (!canonicalRun) canonicalRun = run; + let evidence; + if (value.evidence?.kind === "captured") { + const artifacts = []; + for (const declaration of value.evidence.artifacts ?? []) { + if (controlFiles.has(declaration.path)) { + failures.push(finding("page_evidence_artifact_control_collision", "captured artifacts must be disjoint from manifest, receipt, and runner-result controls", declaration.path)); + continue; + } + const owner = artifactOwners.get(declaration.path); + if (owner) failures.push(finding("page_evidence_artifact_reused", `${declaration.path} is shared by ${owner} and ${value.scenario_id}`, reference)); + else artifactOwners.set(declaration.path, value.scenario_id); + const resolved = readContainedBytes({ prefix: "page_evidence_artifact", reference: declaration.path, root: options.artifactRoot }, failures); + if (!resolved) continue; + expectedFiles.add(declaration.path); + const artifact = metadata(resolved.bytes, declaration.media_type, declaration.path); + if (declaration.media_type === "image/png" && (!artifact.width || !artifact.height)) failures.push(finding("page_evidence_png_invalid", "captured PNG must contain a valid IHDR/IDAT/IEND chunk stream", declaration.path)); + artifacts.push(artifact); + } + evidence = { artifacts, kind: "captured" }; + } else if (value.evidence?.kind === "reused") { + evidence = value.evidence; + if (!prior?.valid) failures.push(finding("page_evidence_reuse_manifest_required", "reused evidence requires a valid --prior-manifest", reference)); + else { + const priorScenario = prior.value.scenarios?.find((scenario) => scenario.id === value.evidence.prior_scenario_id); + if (value.evidence.prior_manifest_sha256 !== prior.sha256) failures.push(finding("page_evidence_reuse_manifest_mismatch", "prior manifest digest does not match", reference)); + if (value.evidence.prior_session_id !== prior.value.session?.session_id || value.evidence.prior_run_id !== prior.value.run?.id) failures.push(finding("page_evidence_reuse_identity_mismatch", "prior session or run identity does not match", reference)); + if (!priorScenario || value.evidence.prior_scenario_id !== value.scenario_id || priorScenario.evidence?.kind !== "captured") failures.push(finding("page_evidence_reuse_scenario_mismatch", "reuse must select the same captured scenario", reference)); + if (receipt && !sameJson(prior.value.session?.source, receipt.value.source)) failures.push(finding("page_evidence_reuse_source_mismatch", "reuse requires byte-identical relevant sources", reference)); + if (priorScenario && !sameJson(priorScenario.semantic_environment, value.semantic_environment)) failures.push(finding("page_evidence_reuse_environment_mismatch", "reuse requires the same semantic environment", reference)); + } + } + scenarios.push({ evidence, id: value.scenario_id, recorded_at: value.recorded_at, runner_result: metadata(runner.bytes, "application/json", reference), semantic_environment: value.semantic_environment }); +} + +if (receipt) { + const passedIds = scenarios.map((scenario) => scenario.id); + if (!sameStringSet(passedIds, receipt.value.intended_scenario_ids)) failures.push(finding("page_evidence_pass_membership_invalid", "PASS scenarios must exactly match the start intent", PAGE_EVIDENCE_MANIFEST)); +} +if (packetWithinLimits && options.artifactRoot && fs.existsSync(options.artifactRoot)) { + for (const reference of artifactFiles) if (!expectedFiles.has(reference)) failures.push(finding("page_evidence_artifact_unmanifested", "artifact root contains a file outside the closed manifest", reference)); +} + +const completedSession = receipt ? { + ...receipt.value, + completed_at: completedAt, + receipt: receipt.artifact, + receipt_sha256: receipt.digest, +} : undefined; +delete completedSession?.record_kind; +delete completedSession?.schema_version; +const manifest = receipt && canonicalRun ? { + claim_boundary: PAGE_EVIDENCE_CLAIM, + completed_at: completedAt, + record_kind: "page_evidence_manifest", + repository: receipt.value.repository, + review_by: options.reviewBy, + revision: receipt.value.revision, + run: canonicalRun, + scenarios, + schema_version: "1.0", + session: completedSession, +} : undefined; +if (manifest && schemas) addSchemaFindings(schemas.manifest, manifest, PAGE_EVIDENCE_MANIFEST, "page_evidence_manifest_schema_invalid", failures); +if (failures.length === 0 && manifestFile) fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" }); +const result = { artifactCount: artifactOwners.size, failures, manifest: manifestFile, ok: failures.length === 0, scenarioCount: scenarios.length, sessionId: receipt?.value.session_id }; +if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +else if (result.ok) process.stdout.write(`finalized ${result.scenarioCount} page evidence scenarios\n`); +else process.stderr.write(`${failures.map((issue) => `${issue.code}: ${issue.path}: ${issue.message}`).join("\n")}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/scripts/generate-consumer-reference-evidence.mjs b/scripts/generate-consumer-reference-evidence.mjs new file mode 100644 index 0000000..5eb437b --- /dev/null +++ b/scripts/generate-consumer-reference-evidence.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveProfileRecords } from "./profile-record-contract.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const options = { check: false, json: false, root: path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local") }; +const failures = []; +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") options.json = true; + else if (argument === "--check") options.check = true; + else if (argument === "--root") { + const value = process.argv[index + 1]; + if (!value) failures.push({ code: "argument_value_required", message: "--root requires a directory", path: "" }); + else { options.root = path.resolve(value); index += 1; } + } else failures.push({ code: "argument_unknown", message: `unsupported argument ${argument}`, path: "" }); +} + +function compare(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function isSorted(values) { + return values.every((value, index) => index === 0 || compare(values[index - 1], value) <= 0); +} + +function duplicateValues(values) { + const seen = new Set(); + return [...new Set(values.filter((value) => seen.has(value) || !seen.add(value)))]; +} + +function escapeMarkdown(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("|", "|") + .replaceAll("`", "`") + .replace(/\r\n?|\n/g, "
"); +} + +function codeCell(value) { + return `${escapeMarkdown(value)}`; +} + +function markdownTable(headers, rows) { + return [`| ${headers.map(escapeMarkdown).join(" | ")} |`, `| ${headers.map(() => "---").join(" | ")} |`, ...rows.map((row) => `| ${row.join(" | ")} |`)].join("\n"); +} + +function frontmatter(title, description) { + return `---\ntype: Generated Evidence\ntitle: ${JSON.stringify(String(title))}\ndescription: ${JSON.stringify(String(description))}\n---\n\n`; +} + +function navigation(parentLabel, parentPath, nextLabel, nextPath) { + return `## IA Navigation\n\nParent: [${parentLabel}](${parentPath}).\nNext: [${nextLabel}](${nextPath}).`; +} + +function renderStateMatrix(profile, states) { + const rows = [...states.scenarios].sort((a, b) => compare(a.id, b.id)).map((scenario) => [ + codeCell(scenario.id), + escapeMarkdown(scenario.semantic_mode), + [...scenario.states].sort(compare).map(codeCell).join(", "), + Object.entries(scenario.aria).sort(([left], [right]) => compare(left, right)).map(([name, value]) => codeCell(`aria-${name}=${value}`)).join("
") || "none", + scenario.expected.visual.map(codeCell).join(", "), + escapeMarkdown(scenario.expected.activation), + ]); + return `${frontmatter(`${profile} button state matrix`, "Compound state sets and channel expectations derived from canonical JSON.")}\n# ${escapeMarkdown(profile)} button state matrix\n\n${markdownTable(["Scenario", "Mode", "State set", "ARIA", "Visual", "Activation"], rows)}\n\n${navigation("Governed Local Profiles", "../../index.md", "Keyboard Matrix", "keyboard-matrix.md")}\n`; +} + +function renderKeyboardMatrix(profile, component, fixture) { + const fixtureByMode = new Map(fixture.scenarios.map((scenario) => [scenario.semantic_mode, scenario.id])); + const rows = [...component.semantic_modes].sort((a, b) => compare(a.id, b.id)).map((mode) => [ + codeCell(mode.id), escapeMarkdown(mode.element), escapeMarkdown(mode.role), [...mode.keys].sort(compare).map(codeCell).join(", "), codeCell(mode.activation), fixtureByMode.has(mode.id) ? codeCell(fixtureByMode.get(mode.id)) : "not exercised", + ]); + return `${frontmatter(`${profile} button keyboard matrix`, "Keyboard behavior and live fixture coverage derived from canonical JSON.")}\n# ${escapeMarkdown(profile)} button keyboard matrix\n\n${markdownTable(["Mode", "Element", "Role", "Keys", "Behavior", "Runtime scenario"], rows)}\n\n${navigation("State Matrix", "state-matrix.md", "Evidence Coverage", "evidence-coverage.md")}\n`; +} + +function renderEvidenceCoverage(profile, fixture, evidence) { + const rows = [...fixture.scenarios].sort((a, b) => compare(a.id, b.id)).map((scenario) => { + const passes = evidence.passes.filter((pass) => pass.scenario_id === scenario.id).sort((a, b) => compare(a.channel, b.channel)); + return [ + codeCell(scenario.id), + scenario.required_channels.map(codeCell).join(", "), + passes.map((pass) => codeCell(pass.channel)).join(", "), + String(passes.length), + passes.map((pass) => codeCell(pass.artifact.path)).join("
"), + ]; + }); + return `${frontmatter(`${profile} button evidence coverage`, "Claim-scoped evidence channels and artifacts derived from canonical JSON.")}\n# ${escapeMarkdown(profile)} button evidence coverage\n\nClaim boundary: ${escapeMarkdown(evidence.claim_boundary)}\n\n${markdownTable(["Scenario", "Required channels", "Recorded channels", "Pass records", "Artifacts"], rows)}\n\n${navigation("Keyboard Matrix", "keyboard-matrix.md", "Reference Profiles", "../../../index.md")}\n`; +} + +function generatedRowIds(content) { + return content.split("\n").map((line) => /^\| ([^<]+)<\/code> \|/.exec(line)?.[1]).filter(Boolean); +} + +function classifyDrift(file, expectedIds, expected, actual) { + const ids = generatedRowIds(actual); + for (const id of duplicateValues(ids)) failures.push({ code: "generated_duplicate_row", message: `${id} is duplicated`, path: file }); + if (!isSorted(ids)) failures.push({ code: "generated_unsorted", message: "generated rows must be sorted", path: file }); + const missing = expectedIds.filter((id) => !ids.includes(id)); + for (const id of missing) failures.push({ code: "generated_missing_row", message: `${id} is missing`, path: file }); + if (ids.length !== expectedIds.length) failures.push({ code: "generated_source_count_mismatch", message: `expected ${expectedIds.length} rows, found ${ids.length}`, path: file }); + if (actual !== expected) failures.push({ code: "generated_stale", message: "generated table differs from canonical JSON", path: file }); +} + +function writeOrCheck(file, expectedIds, content) { + if (options.check) { + if (!fs.existsSync(file)) failures.push({ code: "generated_missing", message: "generated table is missing", path: file }); + else classifyDrift(file, expectedIds, content, fs.readFileSync(file, "utf8")); + return; + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +const profiles = fs.existsSync(options.root) + ? fs.readdirSync(options.root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && fs.existsSync(path.join(options.root, entry.name, "profile.json"))).map((entry) => entry.name).sort(compare) + : []; +let generated = 0; +for (const profile of profiles) { + const profileRoot = path.join(options.root, profile); + const resolved = resolveProfileRecords(profileRoot, failures); + if (!resolved) continue; + const component = resolved.records.component[0]?.value; + const fixture = resolved.records.fixture[0]?.value; + const states = resolved.records.states[0]?.value; + const evidence = resolved.records.evidence[0]?.value; + if (!component || !fixture || !states || !evidence) continue; + const scenarioIds = states.scenarios.map((scenario) => scenario.id); + for (const id of duplicateValues(scenarioIds)) failures.push({ code: "scenario_duplicate", message: `${id} is duplicated`, path: profileRoot }); + if (!isSorted(scenarioIds)) failures.push({ code: "source_unsorted", message: "canonical scenario IDs must be sorted", path: profileRoot }); + const fixtureIds = fixture.scenarios.map((scenario) => scenario.id); + for (const id of fixtureIds.filter((id) => !scenarioIds.includes(id))) failures.push({ code: "fixture_scenario_missing", message: `${id} has no canonical scenario`, path: profileRoot }); + const outputs = new Map([ + ["state-matrix.md", [[...scenarioIds].sort(compare), renderStateMatrix(profile, states)]], + ["keyboard-matrix.md", [component.semantic_modes.map((mode) => mode.id).sort(compare), renderKeyboardMatrix(profile, component, fixture)]], + ["evidence-coverage.md", [[...fixtureIds].sort(compare), renderEvidenceCoverage(profile, fixture, evidence)]], + ]); + for (const record of resolved.records.generated) { + const output = outputs.get(path.basename(record.path)); + if (!output) { failures.push({ code: "generated_reference_unknown", message: `${record.reference} has no renderer`, path: resolved.profileFile }); continue; } + writeOrCheck(record.path, output[0], output[1]); + generated += 1; + } +} +const uniqueFailures = [...new Map(failures.map((failure) => [`${failure.code}:${failure.path}:${failure.message}`, failure])).values()]; +const result = { checkedProfiles: profiles.length, failures: uniqueFailures, generated, mode: options.check ? "check" : "write", ok: uniqueFailures.length === 0, warnings: [] }; +if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +else if (result.ok) process.stdout.write(`${options.check ? "checked" : "generated"} ${generated} consumer reference evidence tables\n`); +else process.stderr.write(`${result.failures.map((failure) => `${failure.code}: ${failure.path}: ${failure.message}`).join("\n")}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/scripts/governance-matrix-contract.mjs b/scripts/governance-matrix-contract.mjs new file mode 100644 index 0000000..7bec3cb --- /dev/null +++ b/scripts/governance-matrix-contract.mjs @@ -0,0 +1,73 @@ +const governedProfileSources = [ + "design-engineering/reference-profiles/governed-local/editorial/profile.json", + "design-engineering/reference-profiles/governed-local/editorial/tokens.dtcg.json", + "design-engineering/reference-profiles/governed-local/editorial/local-foundations.json", + "design-engineering/reference-profiles/governed-local/terminal/profile.json", + "design-engineering/reference-profiles/governed-local/terminal/tokens.dtcg.json", + "design-engineering/reference-profiles/governed-local/terminal/local-foundations.json", +]; + +const componentStateSources = ["components/*.component.json", "states/*.states.json", "fixtures/*.fixture.json", "evidence/*.evidence.json"]; +const componentStateArtifacts = ["editorial", "terminal"].flatMap((profile) => + ["state-matrix.md", "keyboard-matrix.md", "evidence-coverage.md"].map( + (artifact) => `design-engineering/reference-profiles/governed-local/${profile}/generated/${artifact}`, + ), +); + +function matrixRows(governance, family) { + return governance + .split("\n") + .filter((candidate) => /^ {0,3}\|/.test(candidate) && candidate.trimStart().startsWith(`| ${family} |`)) + .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim())); +} + +function codeListValues(cell, finalAnd) { + if (typeof cell !== "string" || cell.length === 0) return null; + const parts = cell.split(", "); + if (finalAnd) { + const last = parts.at(-1); + if (parts.length < 2 || !last?.startsWith("and ")) return null; + parts[parts.length - 1] = last.slice(4); + } else if (parts.some((part) => part.startsWith("and "))) { + return null; + } + const actual = []; + for (const part of parts) { + const match = part.match(/^`([^`]+)`$/); + if (!match) return null; + actual.push(match[1]); + } + return actual; +} + +function hasExpectedValues(actual, expected) { + if (!actual) return false; + return actual.length === expected.length && new Set(actual).size === expected.length && expected.every((value) => actual.includes(value)); +} + +export function referenceOwnershipFailures(governance) { + const failures = []; + const profileRows = matrixRows(governance, "Governed local reference profiles"); + if (profileRows.length !== 1) { + failures.push("GOVERNANCE.md: Governed local reference profiles row must appear exactly once"); + } else { + const profileRow = profileRows[0]; + if (!hasExpectedValues(codeListValues(profileRow[1], false), governedProfileSources)) failures.push("GOVERNANCE.md: governed local reference profile sources must be the six explicit canonical profile files"); + if (profileRow[2] !== "Manual" || profileRow[3] !== "None") failures.push("GOVERNANCE.md: governed local reference profiles must be manual sources with no generated artifacts"); + } + const stateRows = matrixRows(governance, "Component-state evidence matrices"); + if (stateRows.length !== 1) { + failures.push("GOVERNANCE.md: Component-state evidence matrices row must appear exactly once"); + } else { + const stateRow = stateRows[0]; + const sourceCell = stateRow[1]; + const sourcePrefix = "Each profile's declared "; + const sourceSuffix = " records"; + const sourceValues = sourceCell?.startsWith(sourcePrefix) && sourceCell.endsWith(sourceSuffix) + ? codeListValues(sourceCell.slice(sourcePrefix.length, -sourceSuffix.length), true) + : null; + if (!hasExpectedValues(sourceValues, componentStateSources)) failures.push("GOVERNANCE.md: component-state evidence sources must be the four declared record families"); + if (!hasExpectedValues(codeListValues(stateRow[3], false), componentStateArtifacts)) failures.push("GOVERNANCE.md: component-state generated artifacts must be the six declared matrices"); + } + return failures; +} diff --git a/scripts/governance-matrix-negative-cases.mjs b/scripts/governance-matrix-negative-cases.mjs new file mode 100644 index 0000000..e1b09ae --- /dev/null +++ b/scripts/governance-matrix-negative-cases.mjs @@ -0,0 +1,114 @@ +function matrixRow(governance, family) { + return governance.split("\n").find((line) => line.startsWith(`| ${family} |`)); +} + +function duplicateRow(governance, row, replacement = row) { + return governance.replace(row, `${row}\n${replacement}`); +} + +function mutateCell(row, index, mutate) { + const cells = row.split("|"); + cells[index] = ` ${mutate(cells[index].trim())} `; + return cells.join("|"); +} + +function reverseCodeValues(cell) { + const values = [...cell.matchAll(/`([^`]+)`/g)].map((match) => match[1]).reverse(); + return values.map((value) => `\`${value}\``).join(", "); +} + +export function governanceMatrixCases(governance) { + const profileRow = matrixRow(governance, "Governed local reference profiles"); + const stateRow = matrixRow(governance, "Component-state evidence matrices"); + if (!profileRow || !stateRow) throw new Error("canonical governance fixture must contain both reference ownership rows"); + + const conflictingProfileRow = profileRow.replace( + "`design-engineering/reference-profiles/governed-local/editorial/profile.json`", + "`design-engineering/reference-profiles/governed-local/**`", + ); + const reorderedProfileRow = mutateCell(profileRow, 2, reverseCodeValues); + const reorderedStateSources = mutateCell(stateRow, 2, (cell) => { + const values = reverseCodeValues(cell); + return `Each profile's declared ${values.replace(/, ([^,]+)$/, ", and $1")} records`; + }); + const reorderedStateRow = mutateCell(reorderedStateSources, 4, reverseCodeValues); + + return [ + { + name: "duplicate_governed_profile_row", + mutate: { "GOVERNANCE.md": duplicateRow(governance, profileRow) }, + expect: "GOVERNANCE.md: Governed local reference profiles row must appear exactly once", + }, + { + name: "conflicting_duplicate_governed_profile_row", + mutate: { "GOVERNANCE.md": duplicateRow(governance, profileRow, conflictingProfileRow) }, + expect: "GOVERNANCE.md: Governed local reference profiles row must appear exactly once", + }, + { + name: "one_space_duplicate_governed_profile_row", + mutate: { "GOVERNANCE.md": duplicateRow(governance, profileRow, ` ${profileRow}`) }, + expect: "GOVERNANCE.md: Governed local reference profiles row must appear exactly once", + }, + { + name: "three_space_conflicting_governed_profile_row", + mutate: { "GOVERNANCE.md": duplicateRow(governance, profileRow, ` ${conflictingProfileRow}`) }, + expect: "GOVERNANCE.md: Governed local reference profiles row must appear exactly once", + }, + { + name: "duplicate_component_state_row", + mutate: { "GOVERNANCE.md": duplicateRow(governance, stateRow) }, + expect: "GOVERNANCE.md: Component-state evidence matrices row must appear exactly once", + }, + { + name: "one_space_duplicate_component_state_row", + mutate: { "GOVERNANCE.md": duplicateRow(governance, stateRow, ` ${stateRow}`) }, + expect: "GOVERNANCE.md: Component-state evidence matrices row must appear exactly once", + }, + { + name: "three_space_conflicting_component_state_row", + mutate: { "GOVERNANCE.md": duplicateRow(governance, stateRow, ` ${mutateCell(stateRow, 2, () => "Each profile's declared `components/**` records")}`) }, + expect: "GOVERNANCE.md: Component-state evidence matrices row must appear exactly once", + }, + { + name: "duplicate_both_reference_rows", + mutate: { "GOVERNANCE.md": duplicateRow(duplicateRow(governance, profileRow), stateRow) }, + expect: "GOVERNANCE.md: Governed local reference profiles row must appear exactly once", + }, + { + name: "four_space_reference_rows_are_code_blocks", + mutate: { + "GOVERNANCE.md": duplicateRow( + duplicateRow(governance, profileRow, ` ${conflictingProfileRow}`), + stateRow, + ` ${mutateCell(stateRow, 2, () => "Each profile's declared `components/**` records")}`, + ), + }, + expect: null, + }, + { + name: "profile_source_extra_prose", + mutate: { "GOVERNANCE.md": governance.replace(profileRow, mutateCell(profileRow, 2, (cell) => `${cell} plus every generated matrix and evidence receipt`)) }, + expect: "GOVERNANCE.md: governed local reference profile sources must be the six explicit canonical profile files", + }, + { + name: "profile_artifact_extra_prose", + mutate: { "GOVERNANCE.md": governance.replace(profileRow, mutateCell(profileRow, 4, (cell) => `${cell} plus every generated matrix`)) }, + expect: "GOVERNANCE.md: governed local reference profiles must be manual sources with no generated artifacts", + }, + { + name: "component_source_extra_prose", + mutate: { "GOVERNANCE.md": governance.replace(stateRow, mutateCell(stateRow, 2, (cell) => `${cell} plus every related record`)) }, + expect: "GOVERNANCE.md: component-state evidence sources must be the four declared record families", + }, + { + name: "component_artifact_extra_prose", + mutate: { "GOVERNANCE.md": governance.replace(stateRow, mutateCell(stateRow, 4, (cell) => `${cell} plus every generated matrix and evidence receipt`)) }, + expect: "GOVERNANCE.md: component-state generated artifacts must be the six declared matrices", + }, + { + name: "reordered_reference_matrix_cells", + mutate: { "GOVERNANCE.md": governance.replace(profileRow, reorderedProfileRow).replace(stateRow, reorderedStateRow) }, + expect: null, + }, + ]; +} diff --git a/scripts/governance-policy-contract.mjs b/scripts/governance-policy-contract.mjs new file mode 100644 index 0000000..a00a773 --- /dev/null +++ b/scripts/governance-policy-contract.mjs @@ -0,0 +1,49 @@ +export const sentinelProvenanceClauses = [ + "Completed-CI repository, workflow, run ID and attempt, SHA, and artifact-name fields are workflow-recorded, self-asserted metadata, not an external attestation.", + "The self-asserted repository field names the canonical upstream changeroa/StyleGallery; the self-asserted execution_repository field names the actual GitHub Actions repository and is limited to changeroa/StyleGallery or ark-jo/StyleGallery.", + "The committed calibration's external_verification object records an independently checked GitHub Actions run and artifact API identity; artifact.api_digest is distinct from committed_ci.raw_evidence_sha256.", + "Future CI aggregates remain awaiting_external_verification until their uploaded artifact API identity is independently checked.", + "Linux/amd64 repeatability is externally verified only for committed run 29260372260; it does not establish baseline-owner approval or product suitability.", + "Baseline-owner approval remains unclaimed until the named owner explicitly approves it.", + "Synthetic fixtures validate rejection and acceptance behavior only; they are not authenticated provenance.", +]; + +export const requiredCodeowners = [ + "* @changeroa", + "/GOVERNANCE.md @changeroa", + "/README.md @changeroa", + "/index.md @changeroa", + "/AGENTS.md @changeroa", + "/DOMAINS.md @changeroa", + "/layout/ @changeroa", + "/motion/ @changeroa", + "/design-engineering/ @changeroa", + "/game-ui/ @changeroa", + "/platform-guides/ @changeroa", + "/consumer-reference/ @changeroa", + "/consumer-reference/baselines/ @changeroa", + "/consumer-reference/policies/ @changeroa", + "/consumer-reference/fixtures/promotion/ @changeroa", + "/consumer-reference/schema/promotion-rfc.schema.json @changeroa", + "/tests/ @changeroa", + "/playwright.config.mjs @changeroa", + "/GUIDE.md @changeroa", + "/guides/ @changeroa", + "/recipes/ @changeroa", + "/quality/ @changeroa", + "/scripts/pattern-data.mjs @changeroa", + "/scripts/generate-patterns.mjs @changeroa", + "/patterns/ @changeroa", + "/CATALOG.md @changeroa", + "/.github/workflows/validate.yml @changeroa", + "/.github/ @changeroa", + "/scripts/ @changeroa", + "/scripts/validate-*.mjs @changeroa", + "/scripts/test-validate-*.mjs @changeroa", +]; + +export const immutableActionPins = [ + "uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4", + "uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", + "uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4", +]; diff --git a/scripts/governance-test-fixture.mjs b/scripts/governance-test-fixture.mjs new file mode 100644 index 0000000..03f5bb3 --- /dev/null +++ b/scripts/governance-test-fixture.mjs @@ -0,0 +1,321 @@ +import { sentinelProvenanceClauses } from "./governance-policy-contract.mjs"; + +export const generatedWarning = ""; +export { sentinelProvenanceClauses }; + +export const files = { + "AGENTS.md": "# Agent Instructions\n\nSee [Governance, Lifecycle, And Docs-As-Code](GOVERNANCE.md) and [StyleGallery Domains](DOMAINS.md).\n", + "README.md": "# StyleGallery\n\n- [Governance, Lifecycle, And Docs-As-Code](GOVERNANCE.md)\n- [StyleGallery Domains](DOMAINS.md)\n", + "index.md": "# StyleGallery\n\n- [Governance, lifecycle, and docs-as-code](GOVERNANCE.md)\n- [StyleGallery Domains](DOMAINS.md)\n", + "DOMAINS.md": "# StyleGallery Domains\n\n### Consumer Reference Promotion\n\nPromotion records are JSON-only.\n", + "consumer-reference/contract.md": `They are not accepted or promoted RFCs.\nReview truth uses one enum; boolean aliases are invalid.\n${sentinelProvenanceClauses.join("\n")}\n`, + "consumer-reference/index.md": "[Promotion RFC schema](schema/promotion-rfc.schema.json)\n[Shared-experimental promotion policy](policies/shared-experimental.json)\n", + "consumer-reference/fixtures/promotion/manifest.json": '{"id": "promotion-fixture-inventory"}\n', + "consumer-reference/policies/shared-experimental.json": '{"review_independence": "single_account", "promotion_occurred": false, "adopter_attestations": 0, "stable_numeric_threshold": null}\n', + "CATALOG.md": `# Layout Pattern Catalog\n\n${generatedWarning}\n`, + "patterns/index.md": `# Pattern Categories\n\n${generatedWarning}\n`, + "patterns/stacking/index.md": `# Stacking\n\n${generatedWarning}\n`, + "patterns/stacking/stack.md": [ + "---", + "lifecycle: generated", + "generated_from: scripts/generate-patterns.mjs, scripts/pattern-data.mjs", + "---", + "", + "# stack", + "", + generatedWarning, + "", + ].join("\n"), + "quality/evidence/executable-evidence.md": [ + "| Claim | Validator or test | CI command | Positive evidence | Negative evidence | Evidence boundary |", + "| Governance, lifecycle, generated-file, ownership, and stale-content policy remain discoverable and CI-enforced. | `scripts/validate-governance.mjs` and `scripts/test-validate-governance.mjs` | `node scripts/validate-governance.mjs --json`; `node scripts/test-validate-governance.mjs --json` | `GOVERNANCE.md`, `.github/CODEOWNERS`, generated warnings, generated metadata, root links, lifecycle states, stale-audit decision, and CI wiring are present. | Missing governance file, generated warning, generated metadata, CODEOWNERS coverage, or stale policy fixtures must fail. | Proves governance policy is present and linked, not that CODEOWNERS users have verified repository write access. |", + "| Domain topology, metadata, provenance, scope boundaries, and root routes remain enforced. | `scripts/validate-domains.mjs` and `scripts/test-validate-domains.mjs` | `node scripts/validate-domains.mjs --json`; `node scripts/test-validate-domains.mjs` | Five governed domains and their declared leaves are reachable and attributed. | Domain metadata, immutable provenance, scope boundaries, and root-route fixtures must fail. | A full SHA proves content identity syntax, not publisher authenticity or local quality. |", + "Consumer-reference handoffs, schema/runtime parity, and containment remain enforced; repository handoff omissions must fail.", + "Promotion governance remains a closed JSON-only, invariant-scoped, deferred example contract.", + "Synthetic validation does not authenticate adoption, organizations, owners, support capacity, provenance, or a promotion decision.", + "The proposed Chromium sentinel preserves canonical card-grid geometry and truth-derived calibration evidence.", + "Governed-local button states retain source-bound visual, DOM, and accessibility-tree evidence across both example profiles.", + "one clean capture session binds the browser artifacts to the governed source inventory", + "It does not prove product suitability, independent adoption, full accessibility, cross-browser behavior, or owner approval", + "Explicit evidence deadlines are audited weekly without inventing a repository-wide maximum age.", + "Malformed records and auditor failures remain blocking even though due-date findings are advisory.", + "Linux/amd64 20-run calibration and `baseline_owner_approval` remain pending.", + ...sentinelProvenanceClauses, + "", + ].join("\n"), + ".github/CODEOWNERS": [ + "* @changeroa", + "/GOVERNANCE.md @changeroa", + "/README.md @changeroa", + "/index.md @changeroa", + "/AGENTS.md @changeroa", + "/DOMAINS.md @changeroa", + "/GUIDE.md @changeroa", + "/guides/ @changeroa", + "/recipes/ @changeroa", + "/quality/ @changeroa", + "/layout/ @changeroa", + "/motion/ @changeroa", + "/design-engineering/ @changeroa", + "/game-ui/ @changeroa", + "/platform-guides/ @changeroa", + "/consumer-reference/ @changeroa", + "/consumer-reference/baselines/ @changeroa", + "/consumer-reference/policies/ @changeroa", + "/consumer-reference/fixtures/promotion/ @changeroa", + "/consumer-reference/schema/promotion-rfc.schema.json @changeroa", + "/tests/ @changeroa", + "/playwright.config.mjs @changeroa", + "/scripts/pattern-data.mjs @changeroa", + "/scripts/generate-patterns.mjs @changeroa", + "/patterns/ @changeroa", + "/CATALOG.md @changeroa", + "/.github/workflows/validate.yml @changeroa", + "/.github/ @changeroa", + "/scripts/ @changeroa", + "/scripts/validate-*.mjs @changeroa", + "/scripts/test-validate-*.mjs @changeroa", + "", + ].join("\n"), + ".github/workflows/validate.yml": [ + "jobs:", + " validate:", + " steps:", + " - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4", + " with:", + " persist-credentials: false", + " - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", + " - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4", + " - run: |", + "node -c scripts/component-state-workflow-contract.mjs", + "node -c scripts/consumer-evidence-governance-contract.mjs", + "node -c scripts/promotion-fixture-inventory.mjs", + "node -c scripts/promotion-boundary-test-contract.mjs", + "node -c scripts/validate-governance.mjs", + "node -c scripts/test-validate-governance.mjs", + "node scripts/validate-governance.mjs --json", + "node scripts/test-validate-governance.mjs --json", + "node -c scripts/validate-domains.mjs", + "node -c scripts/test-validate-domains.mjs", + "node scripts/validate-domains.mjs --json", + "node scripts/test-validate-domains.mjs", + "node scripts/validate-webpage-workflow.mjs --json", + "node scripts/test-validate-webpage-workflow.mjs --json", + "node -c scripts/validate-consumer-reference.mjs", + "node -c scripts/test-validate-consumer-reference.mjs", + "node -c scripts/consumer-reference-case-runner.mjs", + "node -c scripts/governed-profile-registry.mjs", + "node -c scripts/promotion-attestation-contract.mjs", + "node -c scripts/validate-promotion-rfc.mjs", + "node -c scripts/test-validate-promotion-rfc.mjs", + "node -c scripts/json-schema-formats.mjs", + "node -c scripts/consumer-conformance-contract.mjs", + "node -c scripts/validate-consumer-conformance.mjs", + "node -c scripts/test-validate-consumer-conformance.mjs", + "node -c consumer-reference/fixtures/consumer-conformance/e2e-fixture.mjs", + "node -c scripts/page-evidence-contract.mjs", + "node -c scripts/page-evidence-fixture.mjs", + "node -c scripts/create-page-evidence-session.mjs", + "node -c scripts/finalize-page-evidence.mjs", + "node -c scripts/validate-page-evidence.mjs", + "node -c scripts/test-validate-page-evidence.mjs", + "node -c scripts/audit-evidence-freshness.mjs", + "node -c scripts/test-audit-evidence-freshness.mjs", + "node -c tests/fixtures/consumer-conformance-scenarios.mjs", + "node -c tests/helpers/render-consumer-conformance.mjs", + "node -c tests/consumer-conformance.spec.mjs", + "node -c scripts/test-consumer-conformance-sentinel.mjs", + "node scripts/validate-consumer-reference.mjs --json", + "node scripts/test-validate-consumer-reference.mjs --json", + "node scripts/validate-promotion-rfc.mjs --json", + "node scripts/test-validate-promotion-rfc.mjs --json", + "node scripts/test-validate-consumer-conformance.mjs --case valid-runtime-proof --json", + "node scripts/test-validate-consumer-conformance.mjs --json", + "node scripts/test-validate-page-evidence.mjs --json", + "node scripts/test-audit-evidence-freshness.mjs --json", + `node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/consumer-conformance-record.schema.json"))'`, + `node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/page-evidence-session.schema.json"))'`, + `node -e 'JSON.parse(require("fs").readFileSync("consumer-reference/schema/page-evidence-manifest.schema.json"))'`, + "consumer-reference/schema/promotion-rfc.schema.json", + "consumer-reference/policies/shared-experimental.json", + "node scripts/test-consumer-reference-sentinel.mjs", + "node -c scripts/baseline-schema-parity.mjs", + "node -c scripts/test-component-state-source-contract.mjs", + "node scripts/test-component-state-source-contract.mjs", + "consumer-reference/schema/visual-evidence.schema.json", + " component-state-evidence:", + " env:", + " STATE_EVIDENCE_ROOT: .tmp/consumer-reference-state", + " SENTINEL_CONTAINER_IMAGE: mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a", + " container:", + " image: mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a", + " steps:", + " - name: Run artifact harness", + " run: node scripts/test-validate-component-state-artifacts.mjs", + " - name: Capture evidence", + " run: |", + " node scripts/create-component-state-session.mjs \\", + " --output \"$STATE_EVIDENCE_ROOT/capture-session.json\" \\", + " --json", + " STATE_ARTIFACT_DIR=\"$STATE_EVIDENCE_ROOT/runtime\" \\", + " STATE_SESSION_RECEIPT=\"$STATE_EVIDENCE_ROOT/capture-session.json\" \\", + " npm run test:component-state:runtime -- --reporter=line", + " node scripts/finalize-component-state-evidence.mjs \\", + " --artifact-root \"$STATE_EVIDENCE_ROOT\" \\", + " --output \"$STATE_EVIDENCE_ROOT/runtime-manifest.json\" \\", + " --json", + " node scripts/validate-component-state.mjs \\", + " --artifact-root \"$STATE_EVIDENCE_ROOT\" \\", + " --runtime-manifest \"$STATE_EVIDENCE_ROOT/runtime-manifest.json\" \\", + " --json", + " - name: Upload evidence", + " with:", + " path: ${{ env.STATE_EVIDENCE_ROOT }}/", + " chromium-sentinel:", + " steps:", + " - run: node scripts/test-consumer-reference-sentinel.mjs", + " chromium-calibration:", + " consumer-conformance:", + " name: Consumer conformance Chromium matrix", + " container:", + " image: mcr.microsoft.com/playwright:v1.61.0-noble@sha256:57b65fdc9ceabe0ef613124c7bbe2babcf9362c4d85e382fe3b03604e84b428a", + " steps:", + " - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + " - run: node scripts/test-consumer-conformance-sentinel.mjs --json", + " consumer-page-evidence:", + " name: Consumer page-evidence raster capture (nonblocking)", + " continue-on-error: true", + " env:", + " CONSUMER_CONFORMANCE_CAPTURE_DIR: .tmp/consumer-page-evidence", + " steps:", + " - run: node scripts/create-page-evidence-session.mjs --json", + " - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + " - run: node scripts/finalize-page-evidence.mjs --json", + " - run: node scripts/validate-page-evidence.mjs --json", + " - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4", + " with:", + " name: consumer-page-evidence-${{ github.run_id }}-${{ github.sha }}", + " retention-days: 14", + "node scripts/validate-baseline-manifest.mjs --json", + "node scripts/test-validate-baseline-manifest.mjs --json", + "node scripts/test-summarize-sentinel-calibration.mjs", + "node scripts/validate-renderer-purity.mjs --json", + "checkout_sha=\"$(git -c safe.directory=\"$GITHUB_WORKSPACE\" rev-parse HEAD)\"", + "--repository \"changeroa/StyleGallery\" \\", + "--execution-repository \"$GITHUB_REPOSITORY\" \\", + "\"repository\":\"%s\",\"execution_repository\":\"%s\"", + "\"changeroa/StyleGallery\" \"$GITHUB_REPOSITORY\" \"$GITHUB_RUN_ID\"", + "permissions:", + " contents: read", + "", + ].map((line, index, lines) => { + const commandStart = lines.indexOf("node -c scripts/component-state-workflow-contract.mjs"); + const commandEnd = lines.indexOf("consumer-reference/policies/shared-experimental.json"); + return index >= commandStart && index <= commandEnd ? ` ${line}` : line; + }).join("\n"), + ".github/workflows/evidence-freshness.yml": [ + "name: Audit explicit evidence deadlines", + "on:", + " workflow_dispatch:", + " schedule:", + " - cron: '0 5 * * 1'", + "permissions:", + " contents: read", + "jobs:", + " evidence-freshness:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4", + " with:", + " persist-credentials: false", + " - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", + " - run: npm ci --ignore-scripts --no-audit --no-fund", + " - run: |", + " node scripts/audit-evidence-freshness.mjs --record consumer-reference/baselines/calibration.json --as-of \"$as_of\" --mode advisory --json > evidence-freshness-report.json", + " console.log(`::warning file=${warning.source_path}::${warning.code}: ${warning.message}`);", + " - if: always()", + " uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4", + " with:", + " name: evidence-freshness-${{ github.run_id }}", + " path: evidence-freshness-report.json", + " retention-days: 14", + "", + ].join("\n"), + "GOVERNANCE.md": [ + "---", + "scheduled_stale_audit: deferred", + "scheduled_evidence_audit: active_advisory", + "---", + "", + "# Governance, Lifecycle, And Docs-As-Code", + "", + "| Doc family | Source of truth | Generator | Generated artifacts | Lifecycle state | Stale trigger | Validator | Review owner |", + "| Root repository guide | `README.md` | Manual | None | `stable` | Source-of-truth route changes, broken root links, or ownership changes. | `scripts/validate-okf.mjs` | Repository governance owner |", + "| OKF bundle map | `index.md` | Manual | None | `scripts/validate-okf.mjs` | Repository governance owner |", + "| Agent editing rules | `AGENTS.md` | Manual | None | `scripts/validate-links.mjs` | Repository governance owner |", + "| Planning guides | `GUIDE.md`, `guides/*.md` | Manual | None | `scripts/validate-okf.mjs` | Planning-doc owner |", + "| Layout recipes | `recipes/*.md` | Manual | None | `scripts/validate-okf.mjs` | Recipe owner |", + "| Quality gates and evidence | `quality/**/*.md` | Manual | None | `scripts/validate-okf.mjs` | Quality owner |", + "| Consumer reference contract | `consumer-reference/contract.md` | Manual | None | `stable` | Receiver changes. | `scripts/validate-consumer-reference.mjs` | Validation owner |", + "| Governed local reference profiles | `design-engineering/reference-profiles/governed-local/editorial/profile.json`, `design-engineering/reference-profiles/governed-local/editorial/tokens.dtcg.json`, `design-engineering/reference-profiles/governed-local/editorial/local-foundations.json`, `design-engineering/reference-profiles/governed-local/terminal/profile.json`, `design-engineering/reference-profiles/governed-local/terminal/tokens.dtcg.json`, `design-engineering/reference-profiles/governed-local/terminal/local-foundations.json` | Manual | None | `experimental`, `example_only`, non-default related fixtures | Profile source changes. | `scripts/validate-consumer-reference.mjs` | Validation owner |", + "| Component-state evidence matrices | Each profile's declared `components/*.component.json`, `states/*.states.json`, `fixtures/*.fixture.json`, and `evidence/*.evidence.json` records | `scripts/generate-consumer-reference-evidence.mjs` | `design-engineering/reference-profiles/governed-local/editorial/generated/state-matrix.md`, `design-engineering/reference-profiles/governed-local/editorial/generated/keyboard-matrix.md`, `design-engineering/reference-profiles/governed-local/editorial/generated/evidence-coverage.md`, `design-engineering/reference-profiles/governed-local/terminal/generated/state-matrix.md`, `design-engineering/reference-profiles/governed-local/terminal/generated/keyboard-matrix.md`, `design-engineering/reference-profiles/governed-local/terminal/generated/evidence-coverage.md` | `generated` | Source or generator changes. | `scripts/validate-component-state.mjs` | Validation owner |", + "| Consumer migration conformance | `consumer-reference/schema/consumer-conformance-record.schema.json`, `design-engineering/consumer-migration-readiness.md` | Manual | Consumer-owned conformance records | `experimental` method | Method, dimension, scenario, mapping, or ownership changes. | `scripts/validate-consumer-conformance.mjs`, `scripts/test-validate-consumer-conformance.mjs` | Design Engineering owner with Validation owner |", + "| Consumer page-evidence lifecycle | `consumer-reference/schema/page-evidence-session.schema.json`, `consumer-reference/schema/page-evidence-manifest.schema.json` | Browser/session tooling | Consumer-owned session, runner, manifest, and raster artifacts | `experimental` evidence protocol | Source, revision, session, run, scenario, review deadline, or artifact integrity changes. | `scripts/test-validate-page-evidence.mjs`, deterministic Chromium consumer-conformance job | Validation owner with consumer owner |", + "| Explicit evidence freshness schedule | Evidence records that declare `expires_at` or `review_by` | `.github/workflows/evidence-freshness.yml` | Advisory JSON audit artifact | `stable` narrow schedule | Explicit deadline, auditor, schedule, or inventory changes. | `scripts/audit-evidence-freshness.mjs`, `scripts/test-audit-evidence-freshness.mjs` | Validation owner |", + "| Deterministic consumer browser conformance | `tests/consumer-conformance.spec.mjs`, `tests/fixtures/consumer-conformance-scenarios.mjs`, `tests/helpers/render-consumer-conformance.mjs` | Playwright `1.61.0` in the digest-pinned `linux/amd64` image | Optional page-evidence raster artifact | `experimental` contract with blocking semantic gate and nonblocking raster capture | Viewport, container, content, state, overlay, page-scale, focus, overflow, contrast, semantics, or runtime-error changes. | Playwright matrix and `scripts/test-consumer-conformance-sentinel.mjs` | Validation owner with Design Engineering owner |", + "| Shared promotion policy | Promotion policy | Manual | None | stable | Policy changes. | promotion validator | Validation owner |", + "| Proposed Chromium sentinel | `tests/helpers/render-consumer-reference.mjs`, `consumer-reference/schema/calibration-record.schema.json`, `consumer-reference/baselines/*.json` | Playwright | Snapshot and raw calibration evidence | `experimental` | Renderer or baseline changes. | `scripts/validate-baseline-manifest.mjs` | Validation owner |", + "| Domain manifest and scope decision | `DOMAINS.md`, `quality/claim-records/stylegallery-multidomain-scope.md` | Manual | None | `stable` | Domain membership, repository-scope, or provenance-policy changes. | `scripts/validate-domains.mjs`, `scripts/validate-governance.mjs` | Repository governance owner |", + "| Layout domain hub | `layout/index.md` | Manual | None | `stable` | Layout route or ownership changes. | `scripts/validate-domains.mjs`, `scripts/validate-ia.mjs` | Pattern-data owner |", + "| Motion domain guidance | `motion/*.md` | Manual | None | `experimental` | Upstream revision, evidence boundary, or guidance changes. | `scripts/validate-domains.mjs` | Motion domain owner |", + "| Design Engineering domain guidance | `design-engineering/*.md` | Manual | None | `experimental` | Upstream revision, evidence boundary, or guidance changes. | `scripts/validate-domains.mjs` | Design Engineering domain owner |", + "| Game UI domain guidance | `game-ui/**/*.md` | Manual | None | `experimental` | Classification, hierarchy, engine implementation, evidence boundary, or route changes. | `scripts/validate-domains.mjs` | Game UI domain owner |", + "| Platform Guides domain guidance | `platform-guides/*.md` | Manual | None | `experimental` | Platform version, upstream revision, evidence boundary, or guidance changes. | `scripts/validate-domains.mjs` | Platform Guides domain owner |", + "| Pattern data and examples | `scripts/pattern-data.mjs` | Manual data source | `patterns/**/*.md` | `generated` output from `stable` source | Source-lineage URL changes, generated drift, category changes, or pattern count changes. | `scripts/validate-patterns.mjs`, `scripts/validate-catalog.mjs`, `scripts/validate-governance.mjs` | Pattern-data owner |", + "| Pattern generator | `scripts/generate-patterns.mjs` | Manual code source | `patterns/**/*.md` | `stable` generator, `generated` output | Generated structure changes, generated-warning changes, or generated metadata changes. | `node -c scripts/generate-patterns.mjs` | Pattern-data owner |", + "| Validation scripts | `scripts/validate-*.mjs`, `scripts/test-validate-*.mjs` | Manual code source | CI validation output | `node -c` | Validation owner |", + "| CI workflow | `.github/workflows/validate.yml` | Manual | GitHub Actions run | GitHub Actions | Repository governance owner |", + "", + "## Generated Artifact Policy", + "Do not hand-edit generated artifacts.", + "`scripts/pattern-data.mjs`", + "`scripts/generate-patterns.mjs`", + "`scripts/generate-consumer-reference-evidence.mjs`", + "Browser state evidence begins with `scripts/create-component-state-session.mjs`", + "Receipt creation rejects dirty relevant sources.", + "browser-authored visual sidecars bind the same session, scenario, capture time, source digest, PNG bytes, dimensions, and hash", + "exactly 30 channel passes over the closed 40-file runtime set", + "Validation uses the receipt and completed manifest interval, not a wall-clock maximum age", + "design-engineering/reference-profiles/governed-local/editorial/generated/state-matrix.md", + "design-engineering/reference-profiles/governed-local/editorial/generated/keyboard-matrix.md", + "design-engineering/reference-profiles/governed-local/editorial/generated/evidence-coverage.md", + "design-engineering/reference-profiles/governed-local/terminal/generated/state-matrix.md", + "design-engineering/reference-profiles/governed-local/terminal/generated/keyboard-matrix.md", + "design-engineering/reference-profiles/governed-local/terminal/generated/evidence-coverage.md", + "", + "## Lifecycle States", + "`draft` `stable` `deprecated` `experimental` `generated`", + "", + "## Review Ownership", + "CODEOWNERS", + "owner.enforcement: \"placeholder\"", + "review_independence: \"single_account\"", + "The StyleGallery-local `>=2` independent-consumer gateway applies only to consumer-local → shared-experimental invariant eligibility.", + "Shared stable has no numeric adoption threshold.", + "Normative correctness may waive adoption count only.", + "Synthetic examples have zero durable adopter attestations and record no promotion.", + "A stable contract must never be silently relabeled shared-experimental.", + "", + "## Staleness Control", + "Decision: run a weekly advisory audit only for evidence records that already declare `expires_at` or `review_by`.", + "No repository-wide maximum age or inferred time-to-live applies.", + "Malformed records and auditor failures remain blocking even though due-date findings are advisory.", + ".github/workflows/evidence-freshness.yml", + "Monday at 05:00 UTC", + "Audit trigger:", + "node scripts/validate-links.mjs --json", + "node scripts/test-consumer-reference-sentinel.mjs", + ...sentinelProvenanceClauses, + "", + ].join("\n"), +}; diff --git a/scripts/governance-workflow-negative-cases.mjs b/scripts/governance-workflow-negative-cases.mjs new file mode 100644 index 0000000..668c5d3 --- /dev/null +++ b/scripts/governance-workflow-negative-cases.mjs @@ -0,0 +1,241 @@ +export function workflowSafetyCases(workflow) { + return [ + { + name: "floating_action_ref", + mutate: { ".github/workflows/validate.yml": workflow.replace("uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4", "uses: actions/checkout@v4") }, + expect: ".github/workflows/validate.yml: floating or unlabeled action ref uses: actions/checkout@v4", + }, + { + name: "checkout_credentials_persisted", + mutate: { ".github/workflows/validate.yml": workflow.replace(" persist-credentials: false\n", "") }, + expect: ".github/workflows/validate.yml: every actions/checkout step must set persist-credentials: false", + }, + { + name: "mixed_case_checkout_credentials_missing", + mutate: { ".github/workflows/validate.yml": workflow.replace(" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", " - uses: Actions/Checkout@34e114876b0b11c390a56381ad16ebd13914f8d5\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4") }, + expect: ".github/workflows/validate.yml: every actions/checkout step must set persist-credentials: false", + }, + { + name: "mixed_case_checkout_credentials_true", + mutate: { ".github/workflows/validate.yml": workflow.replace(" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", " - uses: Actions/Checkout@34e114876b0b11c390a56381ad16ebd13914f8d5\n with:\n persist-credentials: true\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4") }, + expect: ".github/workflows/validate.yml: every actions/checkout step must set persist-credentials: false", + }, + { + name: "mixed_case_checkout_credentials_false", + mutate: { ".github/workflows/validate.yml": workflow.replace(" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", " - uses: Actions/Checkout@34e114876b0b11c390a56381ad16ebd13914f8d5\n with:\n persist-credentials: false\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4") }, + }, + { + name: "third_party_floating_action_ref", + mutate: { ".github/workflows/validate.yml": workflow.replace("uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", "uses: attacker/action@main") }, + expect: ".github/workflows/validate.yml: floating or unlabeled action ref uses: attacker/action@main", + }, + { + name: "docker_action_ref", + mutate: { ".github/workflows/validate.yml": workflow.replace(" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", " - uses: docker://alpine:latest\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4") }, + expect: ".github/workflows/validate.yml: docker action refs are forbidden uses: docker://alpine:latest", + }, + { + name: "repository_local_action_ref", + mutate: { ".github/workflows/validate.yml": workflow.replace(" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", " - uses: ./.github/actions/local\n - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4") }, + }, + { + name: "checkout_true_with_later_env_false", + mutate: { ".github/workflows/validate.yml": workflow.replace(" persist-credentials: false\n", " persist-credentials: true\n env:\n persist-credentials: false\n") }, + expect: ".github/workflows/validate.yml: every actions/checkout step must set persist-credentials: false", + }, + { + name: "checkout_true_with_block_scalar_false", + mutate: { ".github/workflows/validate.yml": workflow.replace(" persist-credentials: false\n", " sparse-checkout: |\n persist-credentials: false\n persist-credentials: true\n") }, + expect: ".github/workflows/validate.yml: every actions/checkout step must set persist-credentials: false", + }, + { + name: "checkout_duplicate_credentials", + mutate: { ".github/workflows/validate.yml": workflow.replace(" persist-credentials: false\n", " persist-credentials: false\n persist-credentials: false\n") }, + expect: ".github/workflows/validate.yml: every actions/checkout step must set persist-credentials: false", + }, + { + name: "extra_top_level_permission", + mutate: { ".github/workflows/validate.yml": workflow.replace(" contents: read\n", " contents: read\n issues: read\n") }, + expect: ".github/workflows/validate.yml: top-level permissions must be exactly contents: read", + }, + { + name: "write_top_level_permission", + mutate: { ".github/workflows/validate.yml": workflow.replace(" contents: read\n", " contents: write\n") }, + expect: ".github/workflows/validate.yml: top-level permissions must be exactly contents: read", + }, + { + name: "job_level_permission_override", + mutate: { ".github/workflows/validate.yml": workflow.replace(" validate:\n", " validate:\n permissions:\n contents: read\n") }, + expect: ".github/workflows/validate.yml: job-level permissions overrides are forbidden", + }, + { + name: "single_quoted_third_party_floating_action_ref", + mutate: { ".github/workflows/validate.yml": workflow.replace("uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", "'uses': attacker/action@main") }, + expect: ".github/workflows/validate.yml: floating or unlabeled action ref 'uses': attacker/action@main", + }, + { + name: "double_quoted_third_party_floating_action_ref", + mutate: { ".github/workflows/validate.yml": workflow.replace("uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", "\"uses\": attacker/action@main") }, + expect: ".github/workflows/validate.yml: floating or unlabeled action ref \"uses\": attacker/action@main", + }, + { + name: "single_quoted_checkout_floating_ref", + mutate: { ".github/workflows/validate.yml": quotedCheckout(workflow, "'") }, + expect: ".github/workflows/validate.yml: floating or unlabeled action ref 'uses': actions/checkout@v4", + }, + { + name: "single_quoted_checkout_credentials_persisted", + mutate: { ".github/workflows/validate.yml": quotedCheckout(workflow, "'") }, + expect: ".github/workflows/validate.yml: every actions/checkout step must set persist-credentials: false", + }, + { + name: "double_quoted_checkout_floating_ref", + mutate: { ".github/workflows/validate.yml": quotedCheckout(workflow, "\"") }, + expect: ".github/workflows/validate.yml: floating or unlabeled action ref \"uses\": actions/checkout@v4", + }, + { + name: "double_quoted_checkout_credentials_persisted", + mutate: { ".github/workflows/validate.yml": quotedCheckout(workflow, "\"") }, + expect: ".github/workflows/validate.yml: every actions/checkout step must set persist-credentials: false", + }, + { + name: "single_quoted_job_level_permission_override", + mutate: { ".github/workflows/validate.yml": workflow.replace(" validate:\n", " validate:\n 'permissions':\n contents: read\n") }, + expect: ".github/workflows/validate.yml: job-level permissions overrides are forbidden", + }, + { + name: "double_quoted_job_level_permission_override", + mutate: { ".github/workflows/validate.yml": workflow.replace(" validate:\n", " validate:\n \"permissions\":\n contents: read\n") }, + expect: ".github/workflows/validate.yml: job-level permissions overrides are forbidden", + }, + { + name: "flow_sequence_action_mapping", + mutate: { ".github/workflows/validate.yml": workflow.replace("- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", "- { uses: attacker/action@main }") }, + expect: ".github/workflows/validate.yml: flow-style sequence mappings are forbidden", + }, + { + name: "flow_sequence_checkout_with_inline_map", + mutate: { ".github/workflows/validate.yml": flowCheckout(workflow) }, + expect: ".github/workflows/validate.yml: flow-style sequence mappings are forbidden", + }, + { + name: "flow_collection_mapping_value", + mutate: { ".github/workflows/validate.yml": workflow.replace(" steps:\n", " steps: [{ uses: attacker/action@main }]\n") }, + expect: ".github/workflows/validate.yml: flow-style collection values are forbidden", + }, + { + name: "flow_collection_continuation_value", + mutate: { ".github/workflows/validate.yml": workflow.replace(" steps:\n", " steps:\n [{ uses: attacker/action@main }]\n") }, + expect: ".github/workflows/validate.yml: flow-style collection values are forbidden", + }, + { + name: "double_quoted_escaped_action_key", + mutate: { ".github/workflows/validate.yml": workflow.replace("uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", "\"u\\u0073es\": attacker/action@main") }, + expect: ".github/workflows/validate.yml: escape sequences in double-quoted mapping keys are forbidden", + }, + { + name: "double_quoted_escaped_permission_key", + mutate: { ".github/workflows/validate.yml": workflow.replace(" validate:\n", " validate:\n \"permi\\u0073sions\":\n contents: write\n") }, + expect: ".github/workflows/validate.yml: escape sequences in double-quoted mapping keys are forbidden", + }, + { + name: "explicit_action_mapping_key", + mutate: { ".github/workflows/validate.yml": replaceSetupNode(workflow, " ? uses\n : attacker/action@main") }, + expect: ".github/workflows/validate.yml: explicit mapping keys are forbidden", + }, + { + name: "explicit_checkout_mapping_key", + mutate: { ".github/workflows/validate.yml": replaceCheckout(workflow, " - ? uses\n : actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5\n with:\n persist-credentials: true") }, + expect: ".github/workflows/validate.yml: explicit mapping keys are forbidden", + }, + { + name: "explicit_permissions_mapping_key", + mutate: { ".github/workflows/validate.yml": workflow.replace("permissions:\n contents: read", "? permissions\n:\n contents: write") }, + expect: ".github/workflows/validate.yml: explicit mapping keys are forbidden", + }, + { + name: "explicit_duplicate_checkout_credentials_key", + mutate: { ".github/workflows/validate.yml": workflow.replace(" persist-credentials: false\n", " ? persist-credentials\n : false\n persist-credentials: true\n") }, + expect: ".github/workflows/validate.yml: explicit mapping keys are forbidden", + }, + { + name: "tagged_action_key", + mutate: { ".github/workflows/validate.yml": replaceCheckout(workflow, " - name: Checkout\n !!str uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5") }, + expect: ".github/workflows/validate.yml: YAML tags are forbidden", + }, + { + name: "anchored_action_key", + mutate: { ".github/workflows/validate.yml": workflow.replace("uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", "&name uses: attacker/action@main") }, + expect: ".github/workflows/validate.yml: YAML anchors and aliases are forbidden", + }, + { + name: "aliased_action_value", + mutate: { ".github/workflows/validate.yml": workflow.replace("name: Validate StyleGallery", "x-action: &action attacker/action@main\nname: Validate StyleGallery").replace("uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", "uses: *action") }, + expect: ".github/workflows/validate.yml: YAML anchors and aliases are forbidden", + }, + { + name: "merged_checkout_step", + mutate: { ".github/workflows/validate.yml": workflow.replace("name: Validate StyleGallery", "x-checkout: &checkout\n uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5\n with:\n persist-credentials: false\nname: Validate StyleGallery").replace(" with:\n persist-credentials: false", " <<: *checkout\n with:\n persist-credentials: true") }, + expect: ".github/workflows/validate.yml: YAML merge keys are forbidden", + }, + { + name: "literal_block_scalar_action_ref", + mutate: { ".github/workflows/validate.yml": workflow.replace("uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4", "uses: |-\n attacker/action@main") }, + expect: ".github/workflows/validate.yml: block scalar action refs are forbidden", + }, + { + name: "folded_block_scalar_checkout_ref", + mutate: { ".github/workflows/validate.yml": replaceCheckout(workflow, " - name: Checkout\n uses: >-\n actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5\n with:\n persist-credentials: true") }, + expect: ".github/workflows/validate.yml: block scalar action refs are forbidden", + }, + { + name: "yaml_directive", + mutate: { ".github/workflows/validate.yml": `%YAML 1.2\n---\n${workflow}` }, + expect: ".github/workflows/validate.yml: YAML directives are forbidden", + }, + { + name: "yaml_tag_directive", + mutate: { ".github/workflows/validate.yml": `%TAG !e! tag:example.com,2026:\n---\n${workflow}` }, + expect: ".github/workflows/validate.yml: YAML directives are forbidden", + }, + { + name: "yaml_document_start_marker", + mutate: { ".github/workflows/validate.yml": `---\n${workflow}` }, + expect: ".github/workflows/validate.yml: YAML document markers are forbidden", + }, + { + name: "yaml_document_end_marker", + mutate: { ".github/workflows/validate.yml": `${workflow}\n...\n` }, + expect: ".github/workflows/validate.yml: YAML document markers are forbidden", + }, + { + name: "block_scalar_body_structural_notation_ignored", + mutate: { ".github/workflows/validate.yml": workflow.replace(" run: |\n node scripts/create-component-state-session.mjs", " run: |\n ? uses\n : attacker/action@main\n !!str uses: attacker/action@main\n &action uses: attacker/action@main\n <<: *action\n uses: |\n attacker/action@main\n steps: [{ uses: attacker/action@main }]\n ---\n node scripts/create-component-state-session.mjs") }, + }, + ]; +} + +function quotedCheckout(workflow, quote) { + return workflow + .replace("uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4", `${quote}uses${quote}: actions/checkout@v4`) + .replace(" with:\n persist-credentials: false", ` ${quote}with${quote}:\n ${quote}persist-credentials${quote}: true`); +} + +function flowCheckout(workflow) { + return replaceCheckout( + workflow, + " - { uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5, with: { persist-credentials: false } }", + ); +} + +function replaceCheckout(workflow, replacement) { + const named = " - name: Checkout\n uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4\n with:\n persist-credentials: false"; + const unnamed = " - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4\n with:\n persist-credentials: false"; + return workflow.includes(named) ? workflow.replace(named, replacement) : workflow.replace(unnamed, replacement); +} + +function replaceSetupNode(workflow, replacement) { + const named = " - name: Setup Node\n uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4"; + const unnamed = " - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4"; + return workflow.includes(named) ? workflow.replace(named, ` - name: Setup Node\n${replacement}`) : workflow.replace(unnamed, ` - ${replacement.trimStart()}`); +} diff --git a/scripts/governed-profile-registry.mjs b/scripts/governed-profile-registry.mjs new file mode 100644 index 0000000..0ca87d9 --- /dev/null +++ b/scripts/governed-profile-registry.mjs @@ -0,0 +1,125 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; +import { isPlainObject, validateItemSchema } from "./consumer-reference-schema.mjs"; +import { resolveProfileRecords } from "./profile-record-contract.mjs"; +import { validateReferenceProfile, validateReferenceProfileSet } from "./reference-profile-contract.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; + +const moduleRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const itemSchema = parseStrictJson(fs.readFileSync(path.join(moduleRoot, "consumer-reference/schema/item.schema.json"), "utf8")); +const validateFullItemSchema = new Ajv2020({ allErrors: true, strict: false }).compile(itemSchema); + +export const canonicalGovernedProfilePaths = Object.freeze([ + "design-engineering/reference-profiles/governed-local/editorial/profile.json", + "design-engineering/reference-profiles/governed-local/terminal/profile.json", +]); + +function finding(code, recordPath, message) { + return { code, message, path: recordPath }; +} + +function isInside(base, target) { + const relative = path.relative(base, target); + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +function resolveRegisteredFile(root, relative, failures) { + if (!canonicalGovernedProfilePaths.includes(relative)) { + failures.push(finding("profile_registry_unregistered", relative, "profile path is not in the canonical governed profile inventory")); + return undefined; + } + const resolvedRoot = path.resolve(root); + const target = path.resolve(resolvedRoot, relative); + if (!isInside(resolvedRoot, target) || target !== path.join(resolvedRoot, relative)) { + failures.push(finding("profile_registry_path_invalid", relative, "registered profile path must remain normalized and repository-local")); + return undefined; + } + let current = resolvedRoot; + for (const segment of relative.split("/")) { + current = path.join(current, segment); + if (!fs.existsSync(current)) { + failures.push(finding("profile_registry_missing", relative, "registered profile path does not exist")); + return undefined; + } + if (fs.lstatSync(current).isSymbolicLink()) { + failures.push(finding("profile_registry_symlink", relative, "registered profile path must not traverse a symlink")); + return undefined; + } + } + if (!fs.lstatSync(target).isFile()) { + failures.push(finding("profile_registry_type_invalid", relative, "registered profile must be a regular JSON file")); + return undefined; + } + const realRoot = fs.realpathSync(resolvedRoot); + const realTarget = fs.realpathSync(target); + if (!isInside(realRoot, realTarget) || realTarget !== path.join(realRoot, relative)) { + failures.push(finding("profile_registry_redirect", relative, "registered profile must not resolve through a filesystem redirect")); + return undefined; + } + return realTarget; +} + +export function validateGovernedProfile({ relative, root }) { + const failures = []; + const file = resolveRegisteredFile(root, relative, failures); + if (!file) return { failures, profile: undefined, summary: undefined }; + let profile; + try { + profile = parseStrictJson(fs.readFileSync(file, "utf8")); + } catch (error) { + failures.push(finding("profile_json_invalid", relative, error instanceof Error ? error.message : String(error))); + return { failures, profile: undefined, summary: undefined }; + } + if (!validateFullItemSchema(profile)) { + for (const error of validateFullItemSchema.errors ?? []) failures.push(finding("item_schema_invalid", relative, `${error.instancePath || "/"} ${error.message}`)); + } + for (const issue of validateItemSchema(profile, itemSchema)) failures.push(finding(issue.code, relative, issue.message)); + const profileValidation = validateReferenceProfile({ item: profile, relative, root }); + failures.push(...profileValidation.failures); + const records = resolveProfileRecords(path.dirname(file), failures); + if (profile.handoff?.status !== "declared" || profile.handoff?.record !== relative) { + failures.push(finding("profile_registry_handoff_mismatch", relative, "registered profile handoff must name its exact inventory path")); + } + if (records?.profile?.id !== profile.id) failures.push(finding("profile_identity_mismatch", relative, "resolved profile identity must match the validated registry item")); + return { failures, profile, summary: profileValidation.summary }; +} + +export function validateGovernedProfileInventory({ root }) { + const records = new Map(); + const failures = []; + const summaries = []; + const resolvedRoot = path.resolve(root); + if (!fs.existsSync(resolvedRoot) || fs.lstatSync(resolvedRoot).isSymbolicLink() || !fs.lstatSync(resolvedRoot).isDirectory() || fs.realpathSync(resolvedRoot) !== resolvedRoot) { + failures.push(finding("profile_registry_root_invalid", resolvedRoot, "governed profile repository root must be a regular non-symlink directory")); + return { failures, records }; + } + for (const relative of canonicalGovernedProfilePaths) { + const result = validateGovernedProfile({ relative, root }); + records.set(relative, result); + failures.push(...result.failures); + if (result.summary) summaries.push(result.summary); + } + const identities = new Map(); + for (const [relative, result] of records) { + if (!isPlainObject(result.profile) || typeof result.profile.id !== "string") continue; + const previous = identities.get(result.profile.id); + if (previous) failures.push(finding("profile_registry_identity_duplicate", relative, `profile identity duplicates ${previous}`)); + else identities.set(result.profile.id, relative); + } + failures.push(...validateReferenceProfileSet(summaries)); + const uniqueFailures = [...new Map(failures.map((issue) => [`${issue.code}:${issue.path}:${issue.message}`, issue])).values()]; + return { failures: uniqueFailures, records }; +} + +export function isIndependentPromotionProfile(profile) { + return isPlainObject(profile) + && profile.profile_kind === "governed_local" + && profile.artifact_mode === "governed_local" + && profile.fixture_independence === "independent" + && !Object.hasOwn(profile, "related_fixture_set_id") + && profile.example_only === false + && profile.maturity !== "deprecated" + && profile.support?.status === "active"; +} diff --git a/scripts/json-schema-formats.mjs b/scripts/json-schema-formats.mjs new file mode 100644 index 0000000..0a20636 --- /dev/null +++ b/scripts/json-schema-formats.mjs @@ -0,0 +1,31 @@ +import addFormats from "ajv-formats"; + +const rfc3339DateTime = /^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +export function isRfc3339DateTime(value) { + if (typeof value !== "string") return false; + const match = rfc3339DateTime.exec(value); + if (!match || !Number.isFinite(Date.parse(value))) return false; + + const year = Number(match.groups.year); + const month = Number(match.groups.month); + const day = Number(match.groups.day); + const hour = Number(match.groups.hour); + const minute = Number(match.groups.minute); + const second = Number(match.groups.second); + const canonical = new Date(0); + canonical.setUTCFullYear(year, month - 1, day); + canonical.setUTCHours(hour, minute, second, 0); + + return canonical.getUTCFullYear() === year + && canonical.getUTCMonth() === month - 1 + && canonical.getUTCDate() === day + && canonical.getUTCHours() === hour + && canonical.getUTCMinutes() === minute + && canonical.getUTCSeconds() === second; +} + +export function addDateTimeFormat(ajv) { + addFormats(ajv, ["date-time"]); + return ajv; +} diff --git a/scripts/page-artifact-metadata.mjs b/scripts/page-artifact-metadata.mjs new file mode 100644 index 0000000..21b0b83 --- /dev/null +++ b/scripts/page-artifact-metadata.mjs @@ -0,0 +1,124 @@ +import crypto from "node:crypto"; +import { inflateSync } from "node:zlib"; + +const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const pngBitDepths = new Map([ + [0, new Set([1, 2, 4, 8, 16])], + [2, new Set([8, 16])], + [3, new Set([1, 2, 4, 8])], + [4, new Set([8, 16])], + [6, new Set([8, 16])], +]); +const pngChannels = new Map([[0, 1], [2, 3], [3, 1], [4, 2], [6, 4]]); +const adam7Passes = Object.freeze([ + [0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], + [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2], +]); +const maxDecodedPngBytes = 256 * 1024 * 1024; +export const MAX_ARTIFACT_FILE_BYTES = 64 * 1024 * 1024; +const maxPngChunkBytes = 32 * 1024 * 1024; +const maxPngCompressedBytes = 64 * 1024 * 1024; +const crcTable = Uint32Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) value = (value >>> 1) ^ (0xedb88320 & -(value & 1)); + return value >>> 0; +}); + +function crc32(bytes) { + let value = 0xffffffff; + for (const byte of bytes) value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8); + return (value ^ 0xffffffff) >>> 0; +} + +function passExtent(size, start, step) { + return size <= start ? 0 : Math.ceil((size - start) / step); +} + +function validateDecodedPng(idat, { bitDepth, colorType, height, interlace, width }) { + const bitsPerPixel = pngChannels.get(colorType) * bitDepth; + const passes = interlace === 0 ? [[0, 0, 1, 1]] : adam7Passes; + const rows = passes.flatMap(([startX, startY, stepX, stepY]) => { + const passWidth = passExtent(width, startX, stepX); + const passHeight = passExtent(height, startY, stepY); + return passWidth === 0 || passHeight === 0 ? [] : [{ bytes: Math.ceil((passWidth * bitsPerPixel) / 8), count: passHeight }]; + }); + const expected = rows.reduce((total, row) => total + (row.bytes + 1) * row.count, 0); + if (!Number.isSafeInteger(expected) || expected <= 0 || expected > maxDecodedPngBytes) return false; + const compressed = Buffer.concat(idat); + let decoded; + let consumed; + try { + const inflated = inflateSync(compressed, { info: true, maxOutputLength: expected + 1 }); + decoded = inflated.buffer; + consumed = inflated.engine.bytesWritten; + } + catch { return false; } + if (consumed !== compressed.length || decoded.length !== expected) return false; + let offset = 0; + for (const row of rows) { + for (let index = 0; index < row.count; index += 1) { + if (decoded[offset] > 4) return false; + offset += row.bytes + 1; + } + } + return offset === decoded.length; +} + +function pngDimensions(bytes) { + if (bytes.length > MAX_ARTIFACT_FILE_BYTES) return undefined; + if (bytes.length < 8 || !bytes.subarray(0, 8).equals(pngSignature)) return undefined; + let dimensions; + let idatClosed = false; + let sawIdat = false; + const idat = []; + let compressedBytes = 0; + let header; + let offset = 8; + while (offset < bytes.length) { + if (bytes.length - offset < 12) return undefined; + const length = bytes.readUInt32BE(offset); + if (length > maxPngChunkBytes) return undefined; + const typeOffset = offset + 4; + const dataOffset = typeOffset + 4; + const crcOffset = dataOffset + length; + const nextOffset = crcOffset + 4; + if (nextOffset > bytes.length) return undefined; + const typeBytes = bytes.subarray(typeOffset, dataOffset); + if ([...typeBytes].some((byte) => !((byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122)))) return undefined; + if (crc32(bytes.subarray(typeOffset, crcOffset)) !== bytes.readUInt32BE(crcOffset)) return undefined; + const type = typeBytes.toString("ascii"); + if (offset === 8 && type !== "IHDR") return undefined; + if (type === "IHDR") { + if (offset !== 8 || length !== 13 || dimensions) return undefined; + const width = bytes.readUInt32BE(dataOffset); + const height = bytes.readUInt32BE(dataOffset + 4); + const bitDepth = bytes[dataOffset + 8]; + const colorType = bytes[dataOffset + 9]; + const bitDepths = pngBitDepths.get(colorType); + if (width === 0 || width > 0x7fffffff || height === 0 || height > 0x7fffffff || !bitDepths?.has(bitDepth)) return undefined; + if (bytes[dataOffset + 10] !== 0 || bytes[dataOffset + 11] !== 0 || bytes[dataOffset + 12] > 1) return undefined; + header = { bitDepth, colorType, height, interlace: bytes[dataOffset + 12], width }; + dimensions = { height, width }; + } else if (type === "IDAT") { + if (!dimensions || idatClosed) return undefined; + sawIdat = true; + idat.push(bytes.subarray(dataOffset, crcOffset)); + compressedBytes += length; + if (compressedBytes > maxPngCompressedBytes) return undefined; + } else if (type === "IEND") { + return length === 0 && nextOffset === bytes.length && dimensions && sawIdat && validateDecodedPng(idat, header) ? dimensions : undefined; + } else if (sawIdat) idatClosed = true; + offset = nextOffset; + } + return undefined; +} + +export function artifactMetadata(bytes, mediaType) { + const metadata = { + byte_length: bytes.length, + media_type: mediaType, + sha256: `sha256:${crypto.createHash("sha256").update(bytes).digest("hex")}`, + }; + if (mediaType === "image/png") Object.assign(metadata, pngDimensions(bytes)); + return metadata; +} diff --git a/scripts/page-evidence-contract.mjs b/scripts/page-evidence-contract.mjs new file mode 100644 index 0000000..f182fba --- /dev/null +++ b/scripts/page-evidence-contract.mjs @@ -0,0 +1,297 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { isDeepStrictEqual } from "node:util"; +import Ajv2020 from "ajv/dist/2020.js"; +import { artifactMetadata, MAX_ARTIFACT_FILE_BYTES } from "./page-artifact-metadata.mjs"; +import { addDateTimeFormat } from "./json-schema-formats.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; + +export const PAGE_EVIDENCE_CLAIM = "Page evidence is claim-scoped runtime evidence, not visual-regression approval or accessibility certification."; +export const PAGE_EVIDENCE_MANIFEST = "page-evidence-manifest.json"; +export const PAGE_EVIDENCE_RECEIPT = "page-evidence-session.json"; +export const PAGE_EVIDENCE_MAX_PACKET_BYTES = 256 * 1024 * 1024; + +export function finding(code, message, recordPath) { + return { code, message, path: recordPath }; +} + +export function digest(bytes) { + return crypto.createHash("sha256").update(bytes).digest("hex"); +} + +export function metadata(bytes, mediaType, reference) { + const result = artifactMetadata(bytes, mediaType); + return { ...result, path: reference, sha256: result.sha256.replace(/^sha256:/, "") }; +} + +export function sameJson(left, right) { + return isDeepStrictEqual(left, right); +} + +export function sameStringSet(left, right) { + return Array.isArray(left) && Array.isArray(right) && left.length === right.length && new Set(left).size === left.length && left.every((entry) => right.includes(entry)); +} + +export function safeRelativePath(reference) { + return typeof reference === "string" + && reference.length > 0 + && !path.posix.isAbsolute(reference) + && !path.win32.isAbsolute(reference) + && !reference.includes("\\") + && !reference.includes("?") + && !reference.includes("#") + && !/^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) + && path.posix.normalize(reference) === reference + && !reference.split("/").some((segment) => segment === "." || segment === ".."); +} + +function isInside(root, target) { + const relative = path.relative(root, target); + return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); +} + +export function normalizeReference(root, candidate) { + const absolute = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(root, candidate); + const reference = path.relative(path.resolve(root), absolute).split(path.sep).join("/"); + return safeRelativePath(reference) ? reference : undefined; +} + +export function resolveContained({ allowMissing = false, expectedType = "file", prefix, reference, root }, failures) { + const absoluteRoot = path.resolve(root); + if (!fs.existsSync(absoluteRoot) || !fs.lstatSync(absoluteRoot).isDirectory()) { + failures.push(finding(`${prefix}_root_invalid`, "trust root must be an existing directory", absoluteRoot)); + return undefined; + } + if (fs.lstatSync(absoluteRoot).isSymbolicLink() || fs.realpathSync(absoluteRoot) !== absoluteRoot) { + failures.push(finding(`${prefix}_redirect`, "trust root must not be a filesystem redirect", absoluteRoot)); + return undefined; + } + if (!safeRelativePath(reference)) { + failures.push(finding(`${prefix}_escape`, "path must be normalized and repository-relative", String(reference))); + return undefined; + } + const target = path.resolve(absoluteRoot, reference); + if (!isInside(absoluteRoot, target)) { + failures.push(finding(`${prefix}_escape`, "path escapes its trust root", reference)); + return undefined; + } + let current = absoluteRoot; + const segments = reference.split("/"); + for (const [index, segment] of segments.entries()) { + current = path.join(current, segment); + if (!fs.existsSync(current)) { + if (allowMissing) return { file: target, reference }; + failures.push(finding(`${prefix}_missing`, "required file is missing", reference)); + return undefined; + } + const stat = fs.lstatSync(current); + if (stat.isSymbolicLink()) { + failures.push(finding(`${prefix}_symlink`, "path must not traverse a symbolic link", reference)); + return undefined; + } + const final = index === segments.length - 1; + if ((!final && !stat.isDirectory()) || (final && expectedType === "file" && !stat.isFile()) || (final && expectedType === "directory" && !stat.isDirectory())) { + failures.push(finding(`${prefix}_type_invalid`, `path must resolve to a regular ${expectedType}`, reference)); + return undefined; + } + } + const canonical = path.join(fs.realpathSync(absoluteRoot), ...segments); + if (fs.realpathSync(target) !== canonical) { + failures.push(finding(`${prefix}_redirect`, "path resolves through a filesystem redirect", reference)); + return undefined; + } + return { file: target, reference }; +} + +export function readJsonFile({ prefix, reference, root }, failures) { + const resolved = readContainedBytes({ prefix, reference, root }, failures); + if (!resolved) return undefined; + try { + return { ...resolved, value: parseStrictJson(resolved.bytes.toString("utf8")) }; + } catch (error) { + failures.push(finding(`${prefix}_json_invalid`, error instanceof Error ? error.message : String(error), reference)); + return undefined; + } +} + +export function readContainedBytes({ prefix, reference, root }, failures) { + const resolved = resolveContained({ expectedType: "file", prefix, reference, root }, failures); + if (!resolved) return undefined; + let descriptor; + try { + descriptor = fs.openSync(resolved.file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + const before = fs.fstatSync(descriptor); + const namedBefore = fs.lstatSync(resolved.file); + if (!before.isFile() || before.dev !== namedBefore.dev || before.ino !== namedBefore.ino) throw new Error("file identity changed before read"); + if (before.size > MAX_ARTIFACT_FILE_BYTES) throw new Error(`file exceeds ${MAX_ARTIFACT_FILE_BYTES} bytes`); + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor); + const namedAfter = fs.lstatSync(resolved.file); + if (before.dev !== after.dev || before.ino !== after.ino || after.dev !== namedAfter.dev || after.ino !== namedAfter.ino || after.size !== bytes.length) throw new Error("file identity changed during read"); + const expected = path.join(fs.realpathSync(path.resolve(root)), ...reference.split("/")); + if (fs.realpathSync(resolved.file) !== expected) throw new Error("file path redirected during read"); + return { ...resolved, bytes }; + } catch (error) { + failures.push(finding(`${prefix}_read_untrusted`, error instanceof Error ? error.message : String(error), reference)); + return undefined; + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + +function readSchema(schemaRoot, name) { + const file = path.join(schemaRoot, name); + try { + return parseStrictJson(fs.readFileSync(file, "utf8")); + } catch (cause) { + throw new Error(`${name} is not strict JSON: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }); + } +} + +export function compilePageEvidenceSchemas(schemaRoot) { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addDateTimeFormat(ajv); + const sessionSchema = readSchema(schemaRoot, "page-evidence-session.schema.json"); + const manifestSchema = readSchema(schemaRoot, "page-evidence-manifest.schema.json"); + ajv.addSchema(sessionSchema); + ajv.addSchema(manifestSchema); + return { + manifest: ajv.getSchema(manifestSchema.$id), + runner: ajv.compile({ $ref: `${manifestSchema.$id}#/$defs/runner_result` }), + session: ajv.getSchema(sessionSchema.$id), + }; +} + +export function addSchemaFindings(validate, value, recordPath, code, failures) { + if (validate(value)) return true; + for (const error of validate.errors ?? []) failures.push(finding(code, `${error.instancePath || "/"} ${error.message}`, recordPath)); + return false; +} + +export function canonicalSourceManifest(root, references, failures) { + if (!Array.isArray(references) || references.length === 0 || new Set(references).size !== references.length) { + failures.push(finding("page_evidence_source_list_invalid", "relevant sources must be a nonempty unique path list", "")); + return undefined; + } + const files = []; + let totalBytes = 0; + for (const reference of [...references].sort()) { + const resolved = readContainedBytes({ prefix: "page_evidence_source", reference, root }, failures); + if (!resolved) continue; + const size = resolved.bytes.length; + totalBytes += size; + if (size > MAX_ARTIFACT_FILE_BYTES || totalBytes > PAGE_EVIDENCE_MAX_PACKET_BYTES) { + failures.push(finding("page_evidence_source_limit", "relevant sources exceed the bounded file or aggregate byte budget", reference)); + continue; + } + const bytes = resolved.bytes; + files.push({ byte_length: bytes.length, path: reference, sha256: digest(bytes) }); + } + return files.length === references.length ? { files, sha256: digest(Buffer.from(JSON.stringify(files))) } : undefined; +} + +function git(root, ...args) { + return execFileSync("git", ["-c", `safe.directory=${path.resolve(root)}`, ...args], { cwd: root, encoding: "utf8" }).trim(); +} + +function remoteIdentity(remote) { + if (!remote) return undefined; + const cleaned = remote.replace(/\.git$/, ""); + const scp = cleaned.match(/^[^@]+@[^:]+:(.+)$/); + if (scp) return scp[1]; + try { + const parsed = new URL(cleaned); + return parsed.pathname.replace(/^\//, ""); + } catch { + return undefined; + } +} + +export function gitIdentity(root, failures) { + try { + const canonical = git(root, "rev-parse", "--show-toplevel"); + if (path.resolve(canonical) !== path.resolve(root) || fs.realpathSync(canonical) !== path.resolve(root)) throw new Error("--root must be the canonical Git worktree root"); + let remote; + try { remote = git(root, "config", "--get", "remote.origin.url"); } catch { remote = ""; } + return { + branch: git(root, "branch", "--show-current") || "detached", + repository: remoteIdentity(remote), + revision: git(root, "rev-parse", "HEAD"), + }; + } catch (error) { + failures.push(finding("page_evidence_git_invalid", error instanceof Error ? error.message : String(error), path.resolve(root))); + return undefined; + } +} + +export function dirtyRelevantSources(root, references) { + const dirty = []; + for (const reference of references) { + try { git(root, "ls-files", "--error-unmatch", "--", reference); } + catch { dirty.push(`untracked:${reference}`); } + } + const status = git(root, "status", "--porcelain=v1", "--untracked-files=all", "--", ...references); + return [...new Set([...dirty, ...status.split("\n").filter(Boolean)])].sort(); +} + +export function readReceipt(artifactRoot, schemas, failures) { + const record = readJsonFile({ prefix: "page_evidence_session", reference: PAGE_EVIDENCE_RECEIPT, root: artifactRoot }, failures); + if (!record) return undefined; + const valid = addSchemaFindings(schemas.session, record.value, record.reference, "page_evidence_session_schema_invalid", failures); + const artifact = metadata(record.bytes, "application/json", PAGE_EVIDENCE_RECEIPT); + return { ...record, artifact, digest: artifact.sha256, valid }; +} + +export function withinSession(recordedAt, startedAt, completedAt) { + const recorded = Date.parse(recordedAt); + const started = Date.parse(startedAt); + const completed = Date.parse(completedAt); + return Number.isFinite(recorded) && Number.isFinite(started) && Number.isFinite(completed) && recorded >= started && recorded <= completed; +} + +export function listArtifactFiles(root, failures) { + const files = []; + let visited = 0; + let limitReported = false; + let totalBytes = 0; + function visit(directory, prefix = "", depth = 0) { + if (depth > 16) { + failures.push(finding("page_evidence_artifact_limit", "artifact tree exceeds the maximum directory depth", prefix)); + return; + } + const handle = fs.opendirSync(directory); + try { + let entry; + while ((entry = handle.readSync()) !== null) { + if (limitReported) return; + const reference = prefix ? `${prefix}/${entry.name}` : entry.name; + visited += 1; + if (visited > 4096) { + if (!limitReported) failures.push(finding("page_evidence_artifact_limit", "artifact tree exceeds 4096 entries", reference)); + limitReported = true; + return; + } + const absolute = path.join(directory, entry.name); + const stat = fs.lstatSync(absolute); + if (stat.isSymbolicLink()) failures.push(finding("page_evidence_artifact_symlink", "artifact root must not contain symbolic links", reference)); + else if (stat.isDirectory()) visit(absolute, reference, depth + 1); + else if (stat.isFile()) { + totalBytes += stat.size; + if (stat.size > MAX_ARTIFACT_FILE_BYTES || totalBytes > PAGE_EVIDENCE_MAX_PACKET_BYTES) { + if (!limitReported) failures.push(finding("page_evidence_artifact_limit", "artifact packet exceeds the bounded file or aggregate byte budget", reference)); + limitReported = true; + return; + } + files.push(reference); + } + else failures.push(finding("page_evidence_artifact_type_invalid", "artifact root contains a non-regular entry", reference)); + } + } finally { + handle.closeSync(); + } + } + visit(root); + return files.sort(); +} diff --git a/scripts/page-evidence-fixture.mjs b/scripts/page-evidence-fixture.mjs new file mode 100644 index 0000000..7afa8dd --- /dev/null +++ b/scripts/page-evidence-fixture.mjs @@ -0,0 +1,271 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { canonicalSourceManifest, digest, metadata } from "./page-evidence-contract.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const creator = path.join(repositoryRoot, "scripts/create-page-evidence-session.mjs"); +export const finalizer = path.join(repositoryRoot, "scripts/finalize-page-evidence.mjs"); +export const validator = path.join(repositoryRoot, "scripts/validate-page-evidence.mjs"); +export const semanticEnvironment = Object.freeze({ + browser: "Chromium 140.0.0", + browser_revision: "chromium-1400", + color_scheme: "light", + locale: "en-US", + page_scale_factor: 1, + platform: "linux/amd64", + reduced_motion: "no-preference", + viewport: { height: 720, width: 320 }, +}); +const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); +const crcTable = Uint32Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit += 1) value = (value >>> 1) ^ (0xedb88320 & -(value & 1)); + return value >>> 0; +}); + +function pngChunk(type, data) { + const name = Buffer.from(type, "ascii"); + const chunk = Buffer.alloc(12 + data.length); + chunk.writeUInt32BE(data.length, 0); + name.copy(chunk, 4); + data.copy(chunk, 8); + let crc = 0xffffffff; + for (const byte of chunk.subarray(4, 8 + data.length)) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); + chunk.writeUInt32BE((crc ^ 0xffffffff) >>> 0, 8 + data.length); + return chunk; +} + +export function truncatedPng() { + return Buffer.from(png.subarray(0, 24)); +} + +export function corruptPngCrc() { + const bytes = Buffer.from(png); + bytes[bytes.length - 1] ^= 0xff; + return bytes; +} + +export function pngWithTrailingBytes() { + return Buffer.concat([png, Buffer.from("trailing bytes")]); +} + +export function pngWithInvalidImageData() { + return Buffer.concat([png.subarray(0, 33), pngChunk("IDAT", Buffer.alloc(0)), png.subarray(png.length - 12)]); +} + +export function pngWithTrailingImageData() { + const length = png.readUInt32BE(33); + const data = png.subarray(41, 41 + length); + return Buffer.concat([png.subarray(0, 33), pngChunk("IDAT", Buffer.concat([data, Buffer.from("trailing")])), png.subarray(45 + length)]); +} + +function git(root, ...args) { + return execFileSync("git", ["-c", `safe.directory=${root}`, ...args], { cwd: root, encoding: "utf8" }).trim(); +} + +export function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +export function runCli(file, args, root, env = {}) { + const fixtureEnv = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("GITHUB_"))); + const child = spawnSync(process.execPath, [file, ...args, "--json"], { cwd: repositoryRoot, encoding: "utf8", env: { ...fixtureEnv, ...env } }); + let report = { failures: [{ code: "child_output_invalid", message: child.stdout || child.stderr, path: file }], ok: false }; + try { report = JSON.parse(child.stdout); } catch (error) { if (!(error instanceof SyntaxError)) throw error; } + return { report, root, status: child.status }; +} + +export function initializeConsumer() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-page-evidence-"))); + execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "ignore" }); + git(root, "config", "user.email", "fixture@example.com"); + git(root, "config", "user.name", "StyleGallery fixture"); + git(root, "remote", "add", "origin", "https://github.com/example/consumer.git"); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.mkdirSync(path.join(root, "tests"), { recursive: true }); + fs.writeFileSync(path.join(root, "src/page.js"), "export const page = 'ready';\n"); + fs.writeFileSync(path.join(root, "tests/page.spec.js"), "const scenario = 'responsive-layout';\nvoid scenario;\n"); + git(root, "add", "src/page.js", "tests/page.spec.js"); + git(root, "commit", "-m", "fixture sources"); + return { + relevantSources: ["src/page.js", "tests/page.spec.js"], + repository: "example/consumer", + revision: git(root, "rev-parse", "HEAD"), + root, + }; +} + +export function createConformance(fixture, { artifactName = "session-a", recordName = artifactName, runId = `run-${artifactName}`, scenarioId = "responsive-layout", sessionId = `session-${artifactName}` } = {}) { + const sourceFailures = []; + const source = canonicalSourceManifest(fixture.root, fixture.relevantSources, sourceFailures); + if (!source || sourceFailures.length > 0) throw new Error(`fixture source manifest failed: ${JSON.stringify(sourceFailures)}`); + const artifactRoot = path.join(fixture.root, "evidence", artifactName); + const recordFile = path.join(fixture.root, "records", `${recordName}.json`); + writeJson(recordFile, { + claim_boundary: "Synthetic conformance fixture; not product certification.", + consumer: { relevant_sources: fixture.relevantSources, repository: fixture.repository, revision: fixture.revision }, + id: `${recordName}-conformance`, + page_evidence: { manifest: path.relative(fixture.root, path.join(artifactRoot, "page-evidence-manifest.json")), status: "applicable" }, + record_kind: "consumer_migration_conformance", + scenarios: [{ + assertions: ["No document overflow."], + argv: ["node", "tests/page.spec.js"], + evidence_method: "browser", + exit_code: 0, + id: scenarioId, + observable_actions: ["Render the page."], + result_artifact: path.relative(fixture.root, path.join(artifactRoot, "runner", `${scenarioId}.json`)), + run_id: runId, + session_id: sessionId, + source_digest: source.sha256, + }], + schema_version: "1.0", + }); + return { artifactRoot, recordFile, runId, scenarioId, sessionId, source }; +} + +export function startSession(fixture, session, env = {}) { + return runCli(creator, ["--root", fixture.root, "--record", session.recordFile, "--artifact-root", session.artifactRoot], fixture.root, env); +} + +export function receiptFor(session) { + const file = path.join(session.artifactRoot, "page-evidence-session.json"); + const bytes = fs.readFileSync(file); + return { bytes, file, sha256: digest(bytes), value: JSON.parse(bytes) }; +} + +export function writeRunner(fixture, session, overrides = {}) { + const receipt = receiptFor(session); + const scenarioId = overrides.scenarioId ?? session.scenarioId; + const runnerReference = `runner/${scenarioId}.json`; + const artifactReference = overrides.artifactPath ?? `captures/${scenarioId}-320.png`; + if (overrides.writeArtifact !== false) { + const artifactFile = path.join(session.artifactRoot, artifactReference); + fs.mkdirSync(path.dirname(artifactFile), { recursive: true }); + fs.writeFileSync(artifactFile, overrides.artifactBytes ?? png); + } + const evidence = overrides.evidence ?? (overrides.status === "failed" + ? { kind: "failed", reason: "synthetic runner failure" } + : { artifacts: [{ media_type: "image/png", path: artifactReference }], kind: "captured" }); + const runner = { + evidence, + nonce: overrides.nonce ?? receipt.value.nonce, + receipt_sha256: overrides.receiptSha256 ?? receipt.sha256, + recorded_at: overrides.recordedAt ?? receipt.value.started_at, + record_kind: "page_evidence_runner_result", + repository: overrides.repository ?? fixture.repository, + revision: overrides.revision ?? fixture.revision, + run: { + attempt: receipt.value.attempt, + id: overrides.runId ?? session.runId, + repository: overrides.runRepository ?? fixture.repository, + revision: overrides.runRevision ?? fixture.revision, + source: "local", + }, + scenario_id: scenarioId, + schema_version: "1.0", + semantic_environment: overrides.semanticEnvironment ?? semanticEnvironment, + session_id: overrides.sessionId ?? session.sessionId, + source_sha256: overrides.sourceSha256 ?? receipt.value.source.sha256, + status: overrides.status ?? "passed", + }; + const runnerFile = path.join(session.artifactRoot, runnerReference); + writeJson(runnerFile, runner); + return { artifactReference, runner, runnerFile, runnerReference }; +} + +export function declareCapturedArtifact(session, runnerRecord, artifactReference) { + if (runnerRecord.artifactReference !== artifactReference) { + fs.rmSync(path.join(session.artifactRoot, runnerRecord.artifactReference), { force: true }); + } + const runner = { + ...runnerRecord.runner, + evidence: { artifacts: [{ media_type: "application/json", path: artifactReference }], kind: "captured" }, + }; + writeJson(runnerRecord.runnerFile, runner); + return { ...runnerRecord, artifactReference, runner }; +} + +export function rewriteCompletedCapture(session, runnerRecord, artifactReference) { + const declared = declareCapturedArtifact(session, runnerRecord, artifactReference); + const manifestFile = path.join(session.artifactRoot, "page-evidence-manifest.json"); + const manifest = JSON.parse(fs.readFileSync(manifestFile)); + const runnerResult = metadata(fs.readFileSync(declared.runnerFile), "application/json", declared.runnerReference); + const artifact = metadata(fs.readFileSync(path.join(session.artifactRoot, artifactReference)), "application/json", artifactReference); + const [scenario, ...remaining] = manifest.scenarios; + writeJson(manifestFile, { + ...manifest, + scenarios: [{ ...scenario, evidence: { artifacts: [artifact], kind: "captured" }, runner_result: runnerResult }, ...remaining], + }); +} + +export function rewriteCompletedArtifact(session, runnerRecord, bytes) { + const artifactFile = path.join(session.artifactRoot, runnerRecord.artifactReference); + fs.writeFileSync(artifactFile, bytes); + const artifact = { ...metadata(bytes, "image/png", runnerRecord.artifactReference), height: 1, width: 1 }; + const manifestFile = path.join(session.artifactRoot, "page-evidence-manifest.json"); + const manifest = JSON.parse(fs.readFileSync(manifestFile)); + const runnerResult = metadata(fs.readFileSync(runnerRecord.runnerFile), "application/json", runnerRecord.runnerReference); + const [scenario, ...remaining] = manifest.scenarios; + writeJson(manifestFile, { + ...manifest, + scenarios: [{ ...scenario, evidence: { artifacts: [artifact], kind: "captured" }, runner_result: runnerResult }, ...remaining], + }); +} + +export function finalizeSession(fixture, session, runner, priorManifest) { + const args = ["--root", fixture.root, "--artifact-root", session.artifactRoot, "--runner-result", runner.runnerReference, "--review-by", "2030-01-01T00:00:00Z"]; + if (priorManifest) args.push("--prior-manifest", priorManifest); + return runCli(finalizer, args, fixture.root); +} + +export function validateSession(fixture, session, priorManifest) { + const args = ["--root", fixture.root, "--artifact-root", session.artifactRoot]; + if (priorManifest) args.push("--prior-manifest", priorManifest); + return runCli(validator, args, fixture.root); +} + +export function completeSession(fixture, session = createConformance(fixture)) { + const start = startSession(fixture, session); + if (start.status !== 0) return { fixture, session, start }; + const runner = writeRunner(fixture, session); + const finalize = finalizeSession(fixture, session, runner); + const validate = finalize.status === 0 ? validateSession(fixture, session) : undefined; + return { finalize, fixture, runner, session, start, validate }; +} + +export function isolated(run) { + const fixture = initializeConsumer(); + try { return run(fixture); } + finally { cleanupFixture(fixture); } +} + +export function started(fixture, options) { + const session = createConformance(fixture, options); + const start = startSession(fixture, session); + if (start.status !== 0) throw new Error(`fixture start failed: ${JSON.stringify(start.report)}`); + return session; +} + +export function reuseSetup(fixture, { beforeCurrent, currentOptions } = {}) { + const prior = completeSession(fixture, createConformance(fixture, { artifactName: "prior" })); + beforeCurrent?.(fixture); + const current = started(fixture, { artifactName: "current", ...currentOptions }); + const priorManifest = path.join(prior.session.artifactRoot, "page-evidence-manifest.json"); + const evidence = { + kind: "reused", + prior_manifest_sha256: digest(fs.readFileSync(priorManifest)), + prior_run_id: prior.session.runId, + prior_scenario_id: prior.session.scenarioId, + prior_session_id: prior.session.sessionId, + }; + return { current, evidence, prior, priorManifest }; +} + +export function cleanupFixture(fixture) { + fs.rmSync(fixture.root, { force: true, recursive: true }); +} diff --git a/scripts/profile-record-contract.mjs b/scripts/profile-record-contract.mjs new file mode 100644 index 0000000..6dc5863 --- /dev/null +++ b/scripts/profile-record-contract.mjs @@ -0,0 +1,140 @@ +import fs from "node:fs"; +import path from "node:path"; +import { parseStrictJson } from "./strict-json.mjs"; + +const referenceKinds = Object.freeze({ + component: { field: "component_records", pattern: /^components\/[a-z0-9-]+\.component\.json$/, required: 1 }, + evidence: { field: "evidence_records", pattern: /^evidence\/[a-z0-9-]+\.evidence\.json$/, required: 1 }, + fixture: { field: "fixture_records", pattern: /^fixtures\/[a-z0-9-]+\.fixture\.json$/, required: 1 }, + generated: { field: "generated_records", pattern: /^generated\/[a-z0-9-]+\.md$/, required: 3 }, + states: { field: "state_records", pattern: /^states\/[a-z0-9-]+\.states\.json$/, required: 1 }, +}); + +function finding(code, file, message) { + return { code, message, path: file }; +} + +function isInside(base, target) { + const relative = path.relative(base, target); + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +function isNormalizedRelative(reference) { + return typeof reference === "string" + && reference.length > 0 + && !path.posix.isAbsolute(reference) + && !path.win32.isAbsolute(reference) + && !reference.includes("\\") + && !reference.includes("?") + && !reference.includes("#") + && !/^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) + && path.posix.normalize(reference) === reference + && !reference.split("/").some((segment) => segment === "." || segment === ".."); +} + +function resolveReference(profileRoot, reference, kind, profileFile, failures) { + const definition = referenceKinds[kind]; + if (!isNormalizedRelative(reference)) { + failures.push(finding("profile_reference_outside", profileFile, `${definition.field} contains an unsafe reference`)); + return undefined; + } + if (!definition.pattern.test(reference)) { + failures.push(finding("profile_reference_unknown", profileFile, `${definition.field} contains unknown reference ${reference}`)); + return undefined; + } + const target = path.resolve(profileRoot, reference); + if (!isInside(profileRoot, target)) { + failures.push(finding("profile_reference_outside", profileFile, `${reference} escapes its profile`)); + return undefined; + } + if (!fs.existsSync(target)) { + failures.push(finding("profile_reference_missing", profileFile, `${reference} does not exist`)); + return undefined; + } + const metadata = fs.lstatSync(target); + if (metadata.isSymbolicLink()) { + failures.push(finding("profile_reference_symlink", profileFile, `${reference} must not be a symlink`)); + return undefined; + } + if (!metadata.isFile()) { + failures.push(finding("profile_reference_type_invalid", profileFile, `${reference} must be a regular file`)); + return undefined; + } + const realRoot = fs.realpathSync(profileRoot); + const realTarget = fs.realpathSync(target); + if (!isInside(realRoot, realTarget)) { + failures.push(finding("profile_reference_outside", profileFile, `${reference} resolves outside its profile`)); + return undefined; + } + if (realTarget !== path.join(realRoot, reference)) { + failures.push(finding("profile_reference_redirect", profileFile, `${reference} resolves through a redirect`)); + return undefined; + } + if (kind === "generated") return { path: realTarget, reference }; + try { + return { path: realTarget, reference, value: parseStrictJson(fs.readFileSync(realTarget, "utf8")) }; + } catch (error) { + failures.push(finding("profile_reference_json_invalid", reference, error instanceof Error ? error.message : String(error))); + return undefined; + } +} + +function resolveFixtureLinks(resolved, profileFile, failures) { + const fixture = resolved.fixture[0]; + const component = resolved.component[0]; + const states = resolved.states[0]; + if (!fixture || !component || !states) return; + for (const [field, expected] of [["component_record", component.path], ["state_record", states.path]]) { + const reference = fixture.value[field]; + const safe = typeof reference === "string" + && reference.length > 0 + && !path.posix.isAbsolute(reference) + && !path.win32.isAbsolute(reference) + && !reference.includes("\\") + && !reference.includes("?") + && !reference.includes("#") + && !/^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) + && path.posix.normalize(reference) === reference; + if (!safe) { + failures.push(finding("fixture_reference_invalid", fixture.reference, `${field} must be normalized and profile-local`)); + continue; + } + const actual = path.resolve(path.dirname(fixture.path), reference); + if (actual !== expected) failures.push(finding("fixture_reference_mismatch", fixture.reference, `${field} does not resolve to the profile-declared record`)); + } + if (fixture.value.profile_id !== component.value.profile_id || fixture.value.profile_id !== states.value.profile_id) { + failures.push(finding("profile_identity_mismatch", profileFile, "fixture links cross profile identities")); + } +} + +export function resolveProfileRecords(profileRoot, failures) { + const profileFile = path.join(profileRoot, "profile.json"); + if (!fs.existsSync(profileFile) || fs.lstatSync(profileFile).isSymbolicLink() || !fs.lstatSync(profileFile).isFile()) { + failures.push(finding("profile_file_invalid", profileFile, "profile.json must be a regular non-symlink file")); + return undefined; + } + let profile; + try { + profile = parseStrictJson(fs.readFileSync(profileFile, "utf8")); + } catch (error) { + failures.push(finding("profile_json_invalid", profileFile, error instanceof Error ? error.message : String(error))); + return undefined; + } + const resolved = {}; + for (const [kind, definition] of Object.entries(referenceKinds)) { + const references = profile[definition.field]; + if (!Array.isArray(references)) { + failures.push(finding("profile_reference_required", profileFile, `${definition.field} must contain exactly ${definition.required} references`)); + resolved[kind] = []; + continue; + } + if (new Set(references).size !== references.length) failures.push(finding("profile_reference_duplicate", profileFile, `${definition.field} must not repeat references`)); + if (references.length !== definition.required) failures.push(finding("profile_reference_required", profileFile, `${definition.field} must contain exactly ${definition.required} references`)); + resolved[kind] = references.map((reference) => resolveReference(profileRoot, reference, kind, profileFile, failures)).filter(Boolean); + } + for (const record of [resolved.component[0], resolved.evidence[0], resolved.fixture[0], resolved.states[0]].filter(Boolean)) { + if (record.value.profile_id !== profile.id) failures.push(finding("profile_identity_mismatch", record.reference, `record profile_id must equal ${profile.id}`)); + } + resolveFixtureLinks(resolved, profileFile, failures); + return { profile, profileFile, records: resolved }; +} diff --git a/scripts/promotion-attestation-contract.mjs b/scripts/promotion-attestation-contract.mjs new file mode 100644 index 0000000..b7efb6f --- /dev/null +++ b/scripts/promotion-attestation-contract.mjs @@ -0,0 +1,91 @@ +import fs from "node:fs"; +import path from "node:path"; +import { isPlainObject } from "./consumer-reference-schema.mjs"; +import { canonicalGovernedProfilePaths, isIndependentPromotionProfile } from "./governed-profile-registry.mjs"; + +function finding(code, message, recordPath) { + return { code, message, path: recordPath }; +} + +function safeProfilePath(relative, profileRoot, recordPath, failures) { + if (typeof relative !== "string" || !/^(?!\/)(?!.*\/\/)(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?![A-Za-z][A-Za-z0-9+.-]*:)[^\\?#]+\.json$/.test(relative)) { + failures.push(finding("promotion_profile_path_invalid", "profile_record must be a normalized repository-relative JSON path", recordPath)); + return undefined; + } + const target = path.resolve(profileRoot, relative); + if (target === profileRoot || !target.startsWith(`${profileRoot}${path.sep}`)) { + failures.push(finding("promotion_profile_path_invalid", "profile_record escapes the repository", recordPath)); + return undefined; + } + let current = profileRoot; + for (const segment of path.relative(profileRoot, target).split(path.sep)) { + current = path.join(current, segment); + if (!fs.existsSync(current)) { + failures.push(finding("promotion_profile_missing", `${relative} does not exist`, recordPath)); + return undefined; + } + if (fs.lstatSync(current).isSymbolicLink()) { + failures.push(finding("promotion_profile_path_symlink", `${relative} traverses a symlink`, recordPath)); + return undefined; + } + } + if (!fs.lstatSync(target).isFile()) { + failures.push(finding("promotion_profile_invalid", `${relative} must be a regular JSON file`, recordPath)); + return undefined; + } + const realRoot = fs.realpathSync(profileRoot); + const real = fs.realpathSync(target); + if (!real.startsWith(`${realRoot}${path.sep}`) || real !== path.join(realRoot, relative)) { + failures.push(finding("promotion_profile_path_invalid", `${relative} resolves outside the repository`, recordPath)); + return undefined; + } + return relative; +} + +export function validatePromotionAttestations({ inventory, profileRoot, recordPath, value }) { + const attestations = Array.isArray(value.attestations) ? value.attestations : []; + const consumers = new Set(); + const countedContexts = new Set(); + const countedOrganizations = new Set(); + const failures = []; + const identities = new Set(); + let eligibleCount = 0; + for (const attestation of attestations) { + if (!isPlainObject(attestation)) continue; + const identity = `${attestation.consumer_id ?? ""}:${attestation.context_id ?? ""}:${attestation.organization_id ?? ""}`; + if (identities.has(identity) || consumers.has(attestation.consumer_id)) failures.push(finding("promotion_attestation_duplicate", `duplicate consumer or attestation identity ${identity}`, recordPath)); + identities.add(identity); + consumers.add(attestation.consumer_id); + const counted = attestation.count_toward_gate === true; + if (attestation.profile_record === undefined) { + failures.push(finding("promotion_attestation_profile_required", `${identity} has no canonical repository-local profile record`, recordPath)); + if (counted) failures.push(finding("promotion_independence_unproven", `${identity} cannot establish counted independence without a profile record`, recordPath)); + continue; + } + const registeredPath = safeProfilePath(attestation.profile_record, profileRoot, recordPath, failures); + if (!registeredPath) continue; + const registered = inventory.records.get(registeredPath); + if (!canonicalGovernedProfilePaths.includes(registeredPath) || !registered || registered.failures.length > 0 || inventory.failures.length > 0) { + failures.push(finding("promotion_independence_unproven", `${attestation.consumer_id} is not a fully validated registered governed profile`, recordPath)); + continue; + } + const profile = registered.profile; + if (!isPlainObject(profile)) continue; + const identityMatches = profile.id === attestation.consumer_id; + if (!identityMatches) failures.push(finding("promotion_profile_identity_mismatch", `${attestation.profile_record} does not match ${attestation.consumer_id}`, recordPath)); + if (!counted) continue; + if (attestation.relationship !== "independent") failures.push(finding("promotion_related_consumer_counted", `${identity} is related and cannot count toward the independent gateway`, recordPath)); + if (countedOrganizations.has(attestation.organization_id) || countedContexts.has(attestation.context_id)) { + failures.push(finding("promotion_independence_unproven", `${identity} does not establish a distinct organization and context`, recordPath)); + } + countedOrganizations.add(attestation.organization_id); + countedContexts.add(attestation.context_id); + const canonicalIndependent = profile.fixture_independence === "independent" && typeof profile.related_fixture_set_id !== "string"; + if (!canonicalIndependent) failures.push(finding("promotion_related_consumer_counted", `${attestation.consumer_id} is a canonical related fixture and cannot count`, recordPath)); + if (identityMatches && attestation.relationship === "independent" && canonicalIndependent && isIndependentPromotionProfile(profile)) eligibleCount += 1; + else if (identityMatches && canonicalIndependent && !isIndependentPromotionProfile(profile)) { + failures.push(finding("promotion_independence_unproven", `${attestation.consumer_id} is not lifecycle- and scope-eligible independent adoption evidence`, recordPath)); + } + } + return { eligibleCount, failures }; +} diff --git a/scripts/promotion-boundary-test-contract.mjs b/scripts/promotion-boundary-test-contract.mjs new file mode 100644 index 0000000..bd17cc4 --- /dev/null +++ b/scripts/promotion-boundary-test-contract.mjs @@ -0,0 +1,92 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function copyTreeNoSymlinks(source, target) { + fs.cpSync(source, target, { recursive: true, filter: (candidate) => { + if (fs.lstatSync(candidate).isSymbolicLink()) throw new Error(`refusing to copy symlink ${candidate}`); + return true; + } }); +} + +function copyFixtureRoot(fixtureRoot, tempRoot, name) { + const root = path.join(tempRoot, name); + copyTreeNoSymlinks(fixtureRoot, root); + return root; +} + +function editJson(file, mutate) { + const value = JSON.parse(fs.readFileSync(file, "utf8")); + mutate(value); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function result(runArguments, name, args, expected) { + const child = runArguments(args); + const codes = child.report.failures.map((failure) => failure.code); + return { actual: { codes, status: child.status }, expected, name, ok: child.status > 0 && codes.includes(expected) }; +} + +export function testPromotionBoundaries({ fixtureRoot, repositoryRoot, runArguments, tempRoot }) { + const results = []; + for (const [name, args] of [["file_option_value", ["--file", "--json"]], ["policy_option_value", ["--policy", "--json"]], ["profile_root_option_value", ["--profile-repository-root", "--json"]]]) { + results.push(result(runArguments, name, args, "argument_value_required")); + } + const yamlRfc = path.join(tempRoot, "promotion.yaml"); + const yamlPolicy = path.join(tempRoot, "policy.yaml"); + fs.copyFileSync(path.join(fixtureRoot, "valid-deferred-example.json"), yamlRfc); + fs.copyFileSync(path.join(repositoryRoot, "consumer-reference/policies/shared-experimental.json"), yamlPolicy); + results.push(result(runArguments, "rfc_yaml_extension", ["--file", yamlRfc, "--json"], "promotion_json_path_required")); + results.push(result(runArguments, "policy_yaml_extension", ["--policy", yamlPolicy, "--json"], "promotion_json_path_required")); + + const linkedRfc = path.join(tempRoot, "linked-rfc.json"); + const linkedPolicy = path.join(tempRoot, "linked-policy.json"); + fs.symlinkSync(path.join(fixtureRoot, "valid-deferred-example.json"), linkedRfc); + fs.symlinkSync(path.join(repositoryRoot, "consumer-reference/policies/shared-experimental.json"), linkedPolicy); + results.push(result(runArguments, "rfc_symlink", ["--file", linkedRfc, "--json"], "promotion_input_path_invalid")); + results.push(result(runArguments, "policy_symlink", ["--policy", linkedPolicy, "--json"], "promotion_input_path_invalid")); + + for (const [name, mutate] of [ + ["inventory_extra_json", (root) => fs.copyFileSync(path.join(fixtureRoot, "valid-deferred-example.json"), path.join(root, "extra.json"))], + ["inventory_extra_yaml", (root) => fs.copyFileSync(path.join(fixtureRoot, "valid-deferred-example.json"), path.join(root, "extra.yaml"))], + ["inventory_missing", (root) => fs.rmSync(path.join(root, "invalid-count-only.json"))], + ["inventory_duplicate", (root) => editJson(path.join(root, "manifest.json"), (value) => value.valid_records.push(value.valid_records[0]))], + ]) { + const root = copyFixtureRoot(fixtureRoot, tempRoot, name); + mutate(root); + results.push(result(runArguments, name, ["--promotion-fixture-root", root, "--json"], "promotion_fixture_inventory_invalid")); + } + + const rejectedRoot = copyFixtureRoot(fixtureRoot, tempRoot, "canonical-rejected"); + editJson(path.join(rejectedRoot, "valid-deferred-example.json"), (value) => { value.decision = "rejected"; }); + results.push(result(runArguments, "canonical_rejected", ["--promotion-fixture-root", rejectedRoot, "--json"], "promotion_canonical_example_invalid")); + const attestedRoot = copyFixtureRoot(fixtureRoot, tempRoot, "canonical-attested"); + editJson(path.join(attestedRoot, "valid-deferred-example.json"), (value) => { value.attestations = [{ attested_by: "Synthetic", context_id: "test", consumer_id: "fake", count_toward_gate: false, organization_id: "test", relationship: "related" }]; }); + results.push(result(runArguments, "canonical_attested", ["--promotion-fixture-root", attestedRoot, "--json"], "promotion_canonical_example_invalid")); + + const unbound = path.join(tempRoot, "unbound.json"); + const duplicateEvidence = path.join(tempRoot, "duplicate-evidence.json"); + fs.copyFileSync(path.join(fixtureRoot, "valid-deferred-example.json"), unbound); + editJson(unbound, (value) => { value.attestations = [{ attested_by: "Synthetic", context_id: "test", consumer_id: "fake", count_toward_gate: false, organization_id: "test", relationship: "related" }]; }); + fs.copyFileSync(path.join(fixtureRoot, "valid-deferred-example.json"), duplicateEvidence); + editJson(duplicateEvidence, (value) => { value.evidence.push(structuredClone(value.evidence[0])); }); + results.push(result(runArguments, "unbound_uncounted", ["--file", unbound, "--json"], "promotion_attestation_profile_required")); + results.push(result(runArguments, "duplicate_evidence_id", ["--file", duplicateEvidence, "--json"], "promotion_evidence_duplicate")); + results.push(result(runArguments, "missing_profile_root", ["--file", path.join(fixtureRoot, "valid-deferred-example.json"), "--profile-repository-root", path.join(tempRoot, "missing-root"), "--json"], "promotion_profile_registry_invalid")); + const linkedProfileRoot = path.join(tempRoot, "linked-profile-root"); + fs.symlinkSync(repositoryRoot, linkedProfileRoot); + results.push(result(runArguments, "symlink_profile_root", ["--file", path.join(fixtureRoot, "valid-deferred-example.json"), "--profile-repository-root", linkedProfileRoot, "--json"], "promotion_profile_registry_invalid")); + const linkedManifestRoot = copyFixtureRoot(fixtureRoot, tempRoot, "linked-manifest-root"); + fs.rmSync(path.join(linkedManifestRoot, "manifest.json")); + fs.symlinkSync(path.join(fixtureRoot, "manifest.json"), path.join(linkedManifestRoot, "manifest.json")); + results.push(result(runArguments, "symlink_manifest", ["--promotion-fixture-root", linkedManifestRoot, "--json"], "promotion_fixture_inventory_invalid")); + + const strictRoot = path.join(tempRoot, "strict-profile-root"); + copyTreeNoSymlinks(path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local"), path.join(strictRoot, "design-engineering/reference-profiles/governed-local")); + const foundations = path.join(strictRoot, "design-engineering/reference-profiles/governed-local/editorial/local-foundations.json"); + fs.writeFileSync(foundations, fs.readFileSync(foundations, "utf8").replace('"identity":', '"identity":"Duplicate",\n "identity":')); + const counted = path.join(tempRoot, "strict-counted.json"); + fs.copyFileSync(path.join(fixtureRoot, "valid-deferred-example.json"), counted); + editJson(counted, (value) => { value.attestations = [{ attested_by: "Synthetic", context_id: "editorial", consumer_id: "editorial-reference-profile", count_toward_gate: true, organization_id: "test", profile_record: "design-engineering/reference-profiles/governed-local/editorial/profile.json", relationship: "independent" }]; }); + results.push(result(runArguments, "duplicate_foundation_identity", ["--file", counted, "--profile-repository-root", strictRoot, "--json"], "promotion_independence_unproven")); + return results; +} diff --git a/scripts/promotion-fixture-inventory.mjs b/scripts/promotion-fixture-inventory.mjs new file mode 100644 index 0000000..515469a --- /dev/null +++ b/scripts/promotion-fixture-inventory.mjs @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import path from "node:path"; +import { parseStrictJson } from "./strict-json.mjs"; + +const validRecords = Object.freeze(["valid-deferred-example.json", "valid-normative-bypass.json"]); +const invalidRecords = Object.freeze([ + ["invalid-blocking-evidence.json", "promotion_blocking_evidence_failed"], + ["invalid-count-only.json", "promotion_count_only_forbidden"], + ["invalid-missing-owner-migration.json", "promotion_owner_migration_required"], + ["invalid-normative-bypass-no-regression.json", "promotion_normative_regression_required"], + ["invalid-related-counted-independent.json", "promotion_related_consumer_counted"], + ["invalid-stable-ended-support.json", "stable_support_ended"], +]); +const policyRecord = "consumer-reference/policies/shared-experimental.json"; +const schemaRecord = "consumer-reference/schema/promotion-rfc.schema.json"; + +function finding(code, message, recordPath) { + return { code, message, path: recordPath }; +} + +function isInside(base, target) { + const relative = path.relative(base, target); + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +function resolveContained({ code, expectedType, repositoryRoot, target }, failures) { + const root = path.resolve(repositoryRoot); + const resolved = path.resolve(target); + const recordPath = path.relative(root, resolved) || resolved; + if (!isInside(root, resolved) || !fs.existsSync(resolved)) { + failures.push(finding(code, "promotion input must exist inside the repository", recordPath)); + return undefined; + } + if (fs.realpathSync(root) !== root) { + failures.push(finding(code, "repository root must not be a filesystem redirect", recordPath)); + return undefined; + } + let current = root; + for (const segment of path.relative(root, resolved).split(path.sep).filter(Boolean)) { + current = path.join(current, segment); + if (fs.lstatSync(current).isSymbolicLink()) { + failures.push(finding(code, "promotion input must not traverse a symlink", recordPath)); + return undefined; + } + } + const metadata = fs.lstatSync(resolved); + if ((expectedType === "file" && !metadata.isFile()) || (expectedType === "directory" && !metadata.isDirectory()) || fs.realpathSync(resolved) !== resolved) { + failures.push(finding(code, `promotion input must be a contained regular ${expectedType}`, recordPath)); + return undefined; + } + return resolved; +} + +export function resolvePromotionJsonFile({ file, repositoryRoot }, failures) { + const recordPath = path.relative(repositoryRoot, path.resolve(file)) || file; + if (path.extname(file) !== ".json") { + failures.push(finding("promotion_json_path_required", "promotion records must use the .json extension", recordPath)); + return undefined; + } + return resolveContained({ code: "promotion_input_path_invalid", expectedType: "file", repositoryRoot, target: file }, failures); +} + +function sameSet(actual, expected) { + return Array.isArray(actual) && actual.length === expected.length && new Set(actual).size === expected.length && expected.every((entry) => actual.includes(entry)); +} + +function validManifest(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const keys = ["id", "invalid_records", "policy_record", "schema_record", "schema_version", "valid_records"]; + if (!sameSet(Object.keys(value), keys) || value.id !== "promotion-fixture-inventory" || value.schema_version !== "1.0" || value.policy_record !== policyRecord || value.schema_record !== schemaRecord || !sameSet(value.valid_records, validRecords)) return false; + if (!Array.isArray(value.invalid_records) || value.invalid_records.length !== invalidRecords.length) return false; + const encoded = value.invalid_records.map((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry) && sameSet(Object.keys(entry), ["expected_code", "file"]) ? `${entry.file}:${entry.expected_code}` : ""); + return sameSet(encoded, invalidRecords.map(([file, code]) => `${file}:${code}`)); +} + +export function loadPromotionFixtureInventory({ fixtureRoot, repositoryRoot }) { + const failures = []; + const root = resolveContained({ code: "promotion_fixture_inventory_invalid", expectedType: "directory", repositoryRoot, target: fixtureRoot }, failures); + if (!root) return { failures, invalidRecords: [], policyFile: undefined, schema: undefined, validFiles: [] }; + const manifestFile = resolveContained({ code: "promotion_fixture_inventory_invalid", expectedType: "file", repositoryRoot, target: path.join(root, "manifest.json") }, failures); + let manifest; + try { + if (manifestFile) manifest = parseStrictJson(fs.readFileSync(manifestFile, "utf8")); + } catch (error) { + failures.push(finding("promotion_fixture_inventory_invalid", error instanceof Error ? error.message : String(error), path.relative(repositoryRoot, manifestFile))); + } + if (!validManifest(manifest)) failures.push(finding("promotion_fixture_inventory_invalid", "promotion manifest must match the canonical closed inventory", path.relative(repositoryRoot, manifestFile ?? root))); + const expectedFiles = ["manifest.json", ...validRecords, ...invalidRecords.map(([file]) => file)]; + const entries = fs.readdirSync(root, { withFileTypes: true }); + if (!sameSet(entries.map((entry) => entry.name), expectedFiles) || entries.some((entry) => !entry.isFile() || path.extname(entry.name) !== ".json")) { + failures.push(finding("promotion_fixture_inventory_invalid", "promotion fixture directory must contain only the declared JSON closed set", path.relative(repositoryRoot, root))); + } + for (const file of expectedFiles) resolveContained({ code: "promotion_fixture_inventory_invalid", expectedType: "file", repositoryRoot, target: path.join(root, file) }, failures); + const policyFile = resolveContained({ code: "promotion_fixture_inventory_invalid", expectedType: "file", repositoryRoot, target: path.join(repositoryRoot, policyRecord) }, failures); + const schemaFile = resolveContained({ code: "promotion_fixture_inventory_invalid", expectedType: "file", repositoryRoot, target: path.join(repositoryRoot, schemaRecord) }, failures); + let schema; + try { + if (schemaFile) schema = parseStrictJson(fs.readFileSync(schemaFile, "utf8")); + } catch (error) { + failures.push(finding("promotion_fixture_inventory_invalid", error instanceof Error ? error.message : String(error), schemaRecord)); + } + return { + failures: [...new Map(failures.map((issue) => [`${issue.code}:${issue.path}:${issue.message}`, issue])).values()], + invalidRecords: invalidRecords.map(([file, expectedCode]) => ({ expectedCode, file: path.join(root, file) })), + policyFile, + schema, + validFiles: validRecords.map((file) => path.join(root, file)), + }; +} diff --git a/scripts/promotion-governance-contract.mjs b/scripts/promotion-governance-contract.mjs new file mode 100644 index 0000000..7e38705 --- /dev/null +++ b/scripts/promotion-governance-contract.mjs @@ -0,0 +1,28 @@ +const requirements = [ + ["GOVERNANCE.md", "The StyleGallery-local `>=2` independent-consumer gateway applies only to consumer-local → shared-experimental invariant eligibility."], + ["GOVERNANCE.md", "Shared stable has no numeric adoption threshold"], + ["GOVERNANCE.md", "may waive adoption count only"], + ["GOVERNANCE.md", "zero durable adopter attestations and record no promotion"], + ["GOVERNANCE.md", "never be silently relabeled shared-experimental"], + ["DOMAINS.md", "### Consumer Reference Promotion"], + ["DOMAINS.md", "Promotion records are JSON-only"], + ["consumer-reference/contract.md", "They are not accepted or promoted RFCs."], + ["consumer-reference/contract.md", "boolean aliases are invalid"], + ["consumer-reference/index.md", "[Promotion RFC schema](schema/promotion-rfc.schema.json)"], + ["consumer-reference/index.md", "[Shared-experimental promotion policy](policies/shared-experimental.json)"], + ["consumer-reference/fixtures/promotion/manifest.json", '"id": "promotion-fixture-inventory"'], + ["quality/evidence/executable-evidence.md", "Promotion governance remains a closed JSON-only, invariant-scoped, deferred example contract."], + ["quality/evidence/executable-evidence.md", "Synthetic validation does not authenticate adoption, organizations, owners, support capacity, provenance, or a promotion decision"], + [".github/workflows/validate.yml", "node -c scripts/promotion-fixture-inventory.mjs"], + [".github/workflows/validate.yml", "node -c scripts/promotion-boundary-test-contract.mjs"], + ["consumer-reference/policies/shared-experimental.json", '"review_independence": "single_account"'], + ["consumer-reference/policies/shared-experimental.json", '"promotion_occurred": false'], + ["consumer-reference/policies/shared-experimental.json", '"adopter_attestations": 0'], + ["consumer-reference/policies/shared-experimental.json", '"stable_numeric_threshold": null'], +]; + +export function promotionGovernanceFailures(read) { + return requirements + .filter(([relative, text]) => !read(relative).includes(text)) + .map(([relative, text]) => `${relative}: missing ${text}`); +} diff --git a/scripts/reference-artifact-path-contract.mjs b/scripts/reference-artifact-path-contract.mjs new file mode 100644 index 0000000..c78a2f9 --- /dev/null +++ b/scripts/reference-artifact-path-contract.mjs @@ -0,0 +1,28 @@ +import fs from "node:fs"; +import path from "node:path"; + +function isContained(root, target) { + const relative = path.relative(root, target); + return relative !== "" && !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative); +} + +export function inspectArtifactPath(root, candidate, allowMissing) { + const trustedRoot = fs.realpathSync(root); + const target = path.resolve(root, candidate); + if (!isContained(trustedRoot, target)) return { ok: false, path: target, reason: "outside_trust_root" }; + + const segments = path.relative(trustedRoot, target).split(path.sep); + let current = trustedRoot; + for (const [index, segment] of segments.entries()) { + current = path.join(current, segment); + if (!fs.existsSync(current)) { + return allowMissing ? { ok: true, path: target } : { ok: false, path: target, reason: "missing" }; + } + const metadata = fs.lstatSync(current); + if (metadata.isSymbolicLink()) return { ok: false, path: target, reason: "symbolic_link" }; + const finalSegment = index === segments.length - 1; + if (finalSegment && !metadata.isFile()) return { ok: false, path: target, reason: "not_regular_file" }; + if (!finalSegment && !metadata.isDirectory()) return { ok: false, path: target, reason: "parent_not_directory" }; + } + return { ok: true, path: target }; +} diff --git a/scripts/reference-profile-contract.mjs b/scripts/reference-profile-contract.mjs new file mode 100644 index 0000000..78da955 --- /dev/null +++ b/scripts/reference-profile-contract.mjs @@ -0,0 +1,114 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { isPlainObject } from "./consumer-reference-schema.mjs"; +import { validatePortableTokens } from "./reference-token-contract.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; + +const layoutSourceSha = "775430bbaf4ee208a642220f440f6926d79c90a3"; +const requiredResetFields = ["body_margin", "box_sizing", "figure_margin"]; + +function hash(content) { + return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`; +} + +function finding(code, relative, message) { + return { code, message, path: relative }; +} + +function isInside(base, target) { + const relative = path.relative(base, target); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +function readJson({ failures, kind, relative, root }) { + const absolute = path.resolve(root, relative); + if (!fs.existsSync(absolute)) { + failures.push(finding(`${kind}_unresolved`, relative, "profile artifact must exist beside profile.json")); + return null; + } + const realRoot = fs.realpathSync(root); + const realTarget = fs.realpathSync(absolute); + if (!isInside(realRoot, realTarget)) { + failures.push(finding(`${kind}_symlink_escape`, relative, "profile artifact resolves outside the repository root")); + return null; + } + if (realTarget !== path.join(realRoot, relative)) { + failures.push(finding(`${kind}_redirect`, relative, "profile artifact must remain at its exact sibling path")); + return null; + } + if (!fs.statSync(realTarget).isFile()) { + failures.push(finding(`${kind}_type_invalid`, relative, "profile artifact must be a regular JSON file")); + return null; + } + const bytes = fs.readFileSync(realTarget); + try { + return { bytes, value: parseStrictJson(bytes.toString("utf8")) }; + } catch (error) { + failures.push(finding(`${kind}_json_invalid`, relative, error instanceof Error ? error.message : String(error))); + return null; + } +} + +export function validateReferenceProfile({ item, relative, root }) { + const failures = []; + const add = (code, message) => failures.push(finding(code, relative, message)); + if (item.profile_kind !== "governed_local") add("profile_kind_governed_local", "profile_kind must be governed_local"); + if (item.artifact_mode !== "governed_local") add("profile_artifact_mode_governed_local", "governed-local profiles cannot be external consumers"); + if (item.maturity !== "experimental") add("profile_maturity_experimental", "governed-local profiles must remain experimental"); + if (item.example_only !== true) add("profile_example_only_required", "governed-local profiles must be example_only"); + if (item.default !== false) add("profile_default_forbidden", "governed-local profiles cannot be implicit or default"); + if (item.layout_source_sha !== layoutSourceSha) add("profile_layout_source_sha_required", `layout_source_sha must pin ${layoutSourceSha}`); + if (typeof item.related_fixture_set_id !== "string" || item.related_fixture_set_id.length === 0) add("profile_related_fixture_set_required", "related_fixture_set_id is required"); + if (!isPlainObject(item.selection) || item.selection.required !== true || item.selection.method !== "profile_path") add("profile_explicit_selection_required", "selection must require an explicit profile_path"); + if (!isPlainObject(item.environment_assumptions) || typeof item.environment_assumptions.user_agent_styles !== "string" || item.environment_assumptions.user_agent_styles.length === 0) { + add("profile_ua_assumption_required", "user-agent style assumptions must be explicit"); + } + const reset = isPlainObject(item.environment_assumptions) ? item.environment_assumptions.reset : null; + if (!isPlainObject(reset) || requiredResetFields.some((field) => typeof reset[field] !== "string" || reset[field].length === 0)) { + add("profile_reset_assumptions_required", "body, box-sizing, and figure reset assumptions must be explicit"); + } + if (item.tokens !== "tokens.dtcg.json") add("profile_tokens_path_required", "tokens must resolve to the sibling tokens.dtcg.json"); + if (item.local_foundations !== "local-foundations.json") add("profile_foundations_path_required", "foundations must resolve to the sibling local-foundations.json"); + + const directory = path.posix.dirname(relative); + const tokenRelative = path.posix.join(directory, "tokens.dtcg.json"); + const foundationRelative = path.posix.join(directory, "local-foundations.json"); + const tokenDocument = readJson({ failures, kind: "profile_tokens", relative: tokenRelative, root }); + const foundations = readJson({ failures, kind: "profile_foundations", relative: foundationRelative, root }); + if (tokenDocument) { + for (const tokenFailure of validatePortableTokens(tokenDocument.value).failures) { + failures.push(finding(tokenFailure.code, tokenRelative, tokenFailure.message)); + } + } + if (foundations) { + if (!isPlainObject(foundations.value) + || foundations.value.profile_id !== item.id + || typeof foundations.value.identity !== "string" + || !isPlainObject(foundations.value.bindings)) { + failures.push(finding("profile_foundations_invalid", foundationRelative, "foundations require matching profile_id, identity, and bindings")); + } + } + + const summary = tokenDocument && foundations ? { + foundationHash: hash(foundations.bytes), + id: item.id, + identity: foundations.value.identity, + layoutSourceSha: item.layout_source_sha, + relatedFixtureSetId: item.related_fixture_set_id, + tokenHash: hash(tokenDocument.bytes), + } : null; + return { failures, summary }; +} + +export function validateReferenceProfileSet(summaries) { + if (summaries.length < 2) return []; + const failures = []; + const unique = (field) => new Set(summaries.map((summary) => summary[field])).size; + if (unique("layoutSourceSha") !== 1) failures.push(finding("profile_layout_source_mismatch", "", "profiles must share one Layout source SHA")); + if (unique("relatedFixtureSetId") !== 1) failures.push(finding("profile_related_set_mismatch", "", "profiles must share one related fixture set")); + if (unique("identity") !== summaries.length) failures.push(finding("profile_identity_not_distinct", "", "adversarial profiles must declare distinct identities")); + if (unique("tokenHash") !== summaries.length) failures.push(finding("profile_tokens_not_distinct", "", "adversarial profiles must have distinct token bytes")); + if (unique("foundationHash") !== summaries.length) failures.push(finding("profile_foundations_not_distinct", "", "adversarial profiles must have distinct foundation bytes")); + return failures; +} diff --git a/scripts/reference-token-contract.mjs b/scripts/reference-token-contract.mjs new file mode 100644 index 0000000..e905712 --- /dev/null +++ b/scripts/reference-token-contract.mjs @@ -0,0 +1,156 @@ +const allowedTypes = new Set(["border", "color", "dimension", "duration"]); +const aliasPattern = /^\{([a-z0-9]+(?:[.-][a-z0-9]+)*)\}$/; +const tokenNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const unsafePathSegments = new Set(["__proto__", "constructor", "prototype"]); + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function finding(code, tokenPath, message) { + return { code, message, path: tokenPath || "" }; +} + +function validatePathSegment(segment, tokenPath) { + if (unsafePathSegments.has(segment)) return [finding("token_path_segment_unsafe", tokenPath, `path segment ${segment} is unsafe for the pinned adapter`)]; + if (!tokenNamePattern.test(segment)) return [finding("token_name_invalid", tokenPath, "token names must use lowercase kebab-case")]; + return []; +} + +function exactKeys(value, allowed, tokenPath) { + return Object.keys(value) + .filter((key) => !allowed.has(key)) + .map((key) => finding("token_value_property_unknown", tokenPath, `unsupported value property ${key}`)); +} + +function validateDimension(value, tokenPath) { + if (!isObject(value)) return [finding("token_value_invalid", tokenPath, "dimension must be an object")]; + const failures = exactKeys(value, new Set(["unit", "value"]), tokenPath); + if (typeof value.value !== "number" || !Number.isFinite(value.value)) failures.push(finding("token_value_invalid", tokenPath, "dimension value must be finite")); + if (!["px", "rem"].includes(value.unit)) failures.push(finding("token_unit_unsupported", tokenPath, "dimension unit must be px or rem")); + return failures; +} + +function validateDuration(value, tokenPath) { + if (!isObject(value)) return [finding("token_value_invalid", tokenPath, "duration must be an object")]; + const failures = exactKeys(value, new Set(["unit", "value"]), tokenPath); + if (typeof value.value !== "number" || !Number.isFinite(value.value) || value.value < 0) failures.push(finding("token_value_invalid", tokenPath, "duration value must be non-negative and finite")); + if (!["ms", "s"].includes(value.unit)) failures.push(finding("token_unit_unsupported", tokenPath, "duration unit must be ms or s")); + return failures; +} + +function validateColor(value, tokenPath) { + if (!isObject(value)) return [finding("token_value_invalid", tokenPath, "color must be an object")]; + const failures = exactKeys(value, new Set(["alpha", "colorSpace", "components"]), tokenPath); + const validComponents = Array.isArray(value.components) && value.components.length === 3 && value.components.every((component) => typeof component === "number" && component >= 0 && component <= 1); + if (value.colorSpace !== "srgb" || !validComponents || (value.alpha !== undefined && (typeof value.alpha !== "number" || value.alpha < 0 || value.alpha > 1))) { + failures.push(finding("token_value_invalid", tokenPath, "color must use bounded sRGB components and optional alpha")); + } + return failures; +} + +function parseReference(value, expectedType, tokenPath) { + const match = typeof value === "string" ? aliasPattern.exec(value) : null; + if (!match) return { failures: [finding("alias_whole_required", tokenPath, "references must be whole-token curly aliases")], references: [] }; + return { failures: [], references: [{ expectedType, source: tokenPath, target: match[1] }] }; +} + +function validateBorder(value, tokenPath) { + if (!isObject(value)) return { failures: [finding("token_value_invalid", tokenPath, "border must be an object")], references: [] }; + const failures = exactKeys(value, new Set(["color", "style", "width"]), tokenPath); + const references = []; + if (value.style !== "solid") failures.push(finding("token_value_invalid", tokenPath, "only the tested solid border style is supported")); + if (typeof value.color === "string") { + const result = parseReference(value.color, "color", tokenPath); + failures.push(...result.failures); + references.push(...result.references); + } else failures.push(...validateColor(value.color, tokenPath)); + if (typeof value.width === "string") { + const result = parseReference(value.width, "dimension", tokenPath); + failures.push(...result.failures); + references.push(...result.references); + } else failures.push(...validateDimension(value.width, tokenPath)); + return { failures, references }; +} + +function validateDirectValue(token) { + if (typeof token.value === "string") return parseReference(token.value, token.type, token.path); + if (token.type === "border") return validateBorder(token.value, token.path); + if (token.type === "color") return { failures: validateColor(token.value, token.path), references: [] }; + if (token.type === "dimension") return { failures: validateDimension(token.value, token.path), references: [] }; + return { failures: validateDuration(token.value, token.path), references: [] }; +} + +export function validatePortableTokens(document) { + const failures = []; + const references = []; + const tokens = []; + if (!isObject(document)) return { failures: [finding("token_document_object_required", "", "token document must be an object")], tokens }; + + function walk(node, segments, inheritedType) { + if (!isObject(node)) { + failures.push(finding("token_node_object_required", segments.join("."), "groups and tokens must be objects")); + return; + } + const tokenPath = segments.join("."); + if (Object.hasOwn(node, "$description") && typeof node.$description !== "string") failures.push(finding("token_description_invalid", tokenPath, "$description must be a string")); + const serialized = JSON.stringify(node); + if (/"(?:resolver|modifiers?|themes?)"\s*:/.test(serialized)) failures.push(finding("token_resolver_forbidden", tokenPath, "resolvers, modifiers, and themes are outside the portable subset")); + const reserved = Object.keys(node).filter((key) => key.startsWith("$")); + if (reserved.includes("$extends")) failures.push(finding("token_extends_forbidden", tokenPath, "$extends is outside the portable subset")); + if (reserved.includes("$ref")) failures.push(finding("token_json_pointer_forbidden", tokenPath, "JSON Pointer references are outside the portable subset")); + for (const key of reserved.filter((key) => !["$description", "$type", "$value", "$extends", "$ref"].includes(key))) { + failures.push(finding("token_unknown_reserved", tokenPath, `unsupported reserved field ${key}`)); + } + const declaredType = node.$type; + if (declaredType !== undefined && !allowedTypes.has(declaredType)) failures.push(finding("token_type_unsupported", tokenPath, `unsupported token type ${String(declaredType)}`)); + const effectiveType = allowedTypes.has(declaredType) ? declaredType : inheritedType; + if (Object.hasOwn(node, "$value")) { + const allowed = new Set(["$description", "$type", "$value"]); + for (const key of Object.keys(node).filter((key) => !allowed.has(key) && !key.startsWith("$"))) failures.push(finding("token_property_unknown", tokenPath, `token contains child property ${key}`)); + if (!effectiveType) failures.push(finding("token_type_required", tokenPath, "token requires an allowed explicit or inherited type")); + else tokens.push({ path: tokenPath, type: effectiveType, value: node.$value }); + return; + } + const children = Object.entries(node).filter(([key]) => !key.startsWith("$")); + if (children.length === 0) failures.push(finding("token_group_empty", tokenPath, "group must contain at least one token")); + for (const [key, child] of children) { + failures.push(...validatePathSegment(key, [...segments, key].join("."))); + walk(child, [...segments, key], effectiveType); + } + } + + for (const [key, node] of Object.entries(document)) { + if (key.startsWith("$")) failures.push(finding("token_unknown_reserved", "", `root field ${key} is unsupported`)); + else { + failures.push(...validatePathSegment(key, key)); + walk(node, [key], undefined); + } + } + for (const token of tokens) { + const result = validateDirectValue(token); + failures.push(...result.failures); + references.push(...result.references); + } + const byPath = new Map(tokens.map((token) => [token.path, token])); + for (const reference of references) { + const target = byPath.get(reference.target); + if (!target) failures.push(finding("alias_dangling", reference.source, `alias target ${reference.target} does not exist`)); + else if (target.type !== reference.expectedType) failures.push(finding("alias_type_mismatch", reference.source, `alias target ${reference.target} has type ${target.type}`)); + } + const edges = new Map(references.filter((reference) => byPath.has(reference.target)).map((reference) => [reference.source, reference.target])); + for (const start of edges.keys()) { + const seen = new Set(); + let cursor = start; + while (edges.has(cursor)) { + if (seen.has(cursor)) { + failures.push(finding("alias_cycle", start, "alias graph contains a cycle")); + break; + } + seen.add(cursor); + cursor = edges.get(cursor); + } + } + const uniqueFailures = [...new Map(failures.map((item) => [`${item.code}:${item.path}:${item.message}`, item])).values()]; + return { failures: uniqueFailures, tokens: tokens.sort((left, right) => left.path.localeCompare(right.path)) }; +} diff --git a/scripts/run-consumer-page-evidence-ci.mjs b/scripts/run-consumer-page-evidence-ci.mjs new file mode 100644 index 0000000..3e921a7 --- /dev/null +++ b/scripts/run-consumer-page-evidence-ci.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { canonicalSourceManifest } from "./page-evidence-contract.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const workspaceRoot = process.cwd(); +const temporaryRoot = path.join(workspaceRoot, ".tmp"); +const syntheticBranch = "main"; +const syntheticRepository = "example/stylegallery-page-evidence-ci"; +const sourcePaths = [ + "playwright.config.mjs", + "tests/consumer-conformance.spec.mjs", + "tests/fixtures/consumer-conformance-scenarios.mjs", + "tests/helpers/render-consumer-conformance.mjs", +]; + +function trustedTemporaryRoot() { + const resolvedWorkspace = path.resolve(workspaceRoot); + if (fs.realpathSync(resolvedWorkspace) !== resolvedWorkspace) throw new Error("workspace root must not be a filesystem redirect"); + if (!fs.existsSync(temporaryRoot)) fs.mkdirSync(temporaryRoot); + const stat = fs.lstatSync(temporaryRoot); + if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(temporaryRoot) !== temporaryRoot) throw new Error(".tmp must be a real directory inside the workspace"); + return temporaryRoot; +} + +function containedPath({ base, candidate, label }) { + if (base === temporaryRoot) trustedTemporaryRoot(); + const resolved = path.resolve(workspaceRoot, candidate); + const relative = path.relative(base, resolved); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(label + " must be inside " + base); + return resolved; +} + +function run({ args, command, cwd }) { + const child = spawnSync(command, args, { cwd, encoding: "utf8" }); + if (child.status !== 0) throw new Error(command + " " + args.join(" ") + " failed (" + child.status + ")\n" + child.stdout + child.stderr); + return child.stdout.trim(); +} + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value, null, 2) + "\n", { flag: "wx" }); +} + +function parseArguments() { + const command = process.argv[2]; + const values = {}; + for (let index = 3; index < process.argv.length; index += 2) { + const name = process.argv[index]; + const value = process.argv[index + 1]; + if (!name?.startsWith("--") || !value || value.startsWith("--")) throw new Error("invalid argument pair at " + (name ?? "")); + values[name.slice(2)] = value; + } + if (!["prepare", "verify-session", "package", "cleanup"].includes(command)) throw new Error("first argument must be prepare, verify-session, package, or cleanup"); + return { command, values }; +} + +function prepare(values) { + const consumerRoot = containedPath({ base: temporaryRoot, candidate: values["consumer-root"], label: "--consumer-root" }); + const recordFile = containedPath({ base: consumerRoot, candidate: values.record, label: "--record" }); + const artifactRoot = containedPath({ base: consumerRoot, candidate: values["artifact-root"], label: "--artifact-root" }); + if (fs.existsSync(consumerRoot)) throw new Error("--consumer-root must not already exist"); + if (!fs.statSync(path.join(repositoryRoot, "node_modules")).isDirectory()) throw new Error("node_modules must exist before preparing the consumer"); + + fs.mkdirSync(consumerRoot, { recursive: true }); + run({ command: "git", args: ["init", "-b", syntheticBranch], cwd: consumerRoot }); + run({ command: "git", args: ["config", "user.email", "page-evidence-ci@example.invalid"], cwd: consumerRoot }); + run({ command: "git", args: ["config", "user.name", "StyleGallery page-evidence CI"], cwd: consumerRoot }); + run({ command: "git", args: ["remote", "add", "origin", `https://github.com/${syntheticRepository}.git`], cwd: consumerRoot }); + + for (const reference of sourcePaths) { + const destination = path.join(consumerRoot, reference); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(path.join(repositoryRoot, reference), destination, fs.constants.COPYFILE_EXCL); + } + fs.symlinkSync(path.join(repositoryRoot, "node_modules"), path.join(consumerRoot, "node_modules"), "dir"); + run({ command: "git", args: ["add", ...sourcePaths], cwd: consumerRoot }); + run({ command: "git", args: ["commit", "-m", "test: prepare consumer page evidence"], cwd: consumerRoot }); + + const revision = run({ command: "git", args: ["rev-parse", "HEAD"], cwd: consumerRoot }); + const sourceFailures = []; + const source = canonicalSourceManifest(consumerRoot, sourcePaths, sourceFailures); + if (!source || sourceFailures.length > 0) throw new Error("source manifest failed: " + JSON.stringify(sourceFailures)); + const runId = process.env.GITHUB_RUN_ID ? "github-" + process.env.GITHUB_RUN_ID : "local-" + revision.slice(0, 12); + const sessionId = "page-evidence-" + crypto.randomUUID(); + const scenarioId = "responsive-layout"; + const reference = (file) => path.relative(consumerRoot, file).split(path.sep).join("/"); + + writeJson(recordFile, { + claim_boundary: "Synthetic CI evidence for the copied consumer-conformance probe; not product certification.", + consumer: { + relevant_sources: sourcePaths, + repository: syntheticRepository, + revision, + }, + id: "stylegallery-consumer-page-evidence-ci", + page_evidence: { + manifest: reference(path.join(artifactRoot, "page-evidence-manifest.json")), + status: "applicable", + }, + record_kind: "consumer_migration_conformance", + scenarios: [{ + assertions: ["The selected responsive layout case passes its browser assertions."], + argv: ["npx", "playwright", "test", "tests/consumer-conformance.spec.mjs", "--project=chromium", "--grep", "state-w1024-focus"], + evidence_method: "browser", + exit_code: 0, + id: scenarioId, + observable_actions: ["Render and capture state-w1024-focus in Chromium."], + result_artifact: reference(path.join(artifactRoot, "runner", scenarioId + ".json")), + run_id: runId, + session_id: sessionId, + source_digest: source.sha256, + }], + schema_version: "1.0", + }); + return { artifactRoot, consumerRoot, recordFile, revision, sourceSha256: source.sha256 }; +} + +function verifySession(values) { + const consumerRoot = containedPath({ base: temporaryRoot, candidate: values["consumer-root"], label: "--consumer-root" }); + const artifactRoot = containedPath({ base: consumerRoot, candidate: values["artifact-root"], label: "--artifact-root" }); + const receiptFile = path.join(artifactRoot, "page-evidence-session.json"); + const receipt = JSON.parse(fs.readFileSync(receiptFile, "utf8")); + const expected = { + branch: syntheticBranch, + repository: syntheticRepository, + revision: run({ command: "git", args: ["rev-parse", "HEAD"], cwd: consumerRoot }), + }; + for (const [field, value] of Object.entries(expected)) { + if (receipt[field] !== value) throw new Error(`page-evidence receipt ${field} must be ${value}, received ${receipt[field]}`); + } + return { ...expected, receiptFile }; +} + +function packagePacket(values) { + const consumerRoot = containedPath({ base: temporaryRoot, candidate: values["consumer-root"], label: "--consumer-root" }); + const artifactRoot = containedPath({ base: consumerRoot, candidate: values["artifact-root"], label: "--artifact-root" }); + const output = containedPath({ base: temporaryRoot, candidate: values.output, label: "--output" }); + const outputRelativeToConsumer = path.relative(consumerRoot, output); + if (!outputRelativeToConsumer.startsWith("..") && !path.isAbsolute(outputRelativeToConsumer)) throw new Error("--output must be outside --consumer-root"); + if (!fs.statSync(artifactRoot).isDirectory()) throw new Error("--artifact-root must be a directory"); + if (fs.existsSync(output)) throw new Error("--output must not already exist"); + fs.mkdirSync(path.dirname(output), { recursive: true }); + fs.cpSync(artifactRoot, output, { errorOnExist: true, force: false, recursive: true }); + return { output }; +} + +function cleanup(values) { + const consumerRoot = containedPath({ base: temporaryRoot, candidate: values["consumer-root"], label: "--consumer-root" }); + if (fs.existsSync(consumerRoot)) { + const stat = fs.lstatSync(consumerRoot); + if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(consumerRoot) !== consumerRoot) throw new Error("--consumer-root must be a real directory before cleanup"); + } + fs.rmSync(consumerRoot, { force: true, recursive: true }); + return { consumerRoot }; +} + +try { + const { command, values } = parseArguments(); + const result = command === "prepare" ? prepare(values) : command === "verify-session" ? verifySession(values) : command === "package" ? packagePacket(values) : cleanup(values); + process.stdout.write(JSON.stringify({ command, ok: true, ...result }, null, 2) + "\n"); +} catch (error) { + process.stderr.write((error instanceof Error ? error.message : String(error)) + "\n"); + process.exitCode = 1; +} diff --git a/scripts/strict-json.mjs b/scripts/strict-json.mjs new file mode 100644 index 0000000..92b6f77 --- /dev/null +++ b/scripts/strict-json.mjs @@ -0,0 +1,72 @@ +export function parseStrictJson(source) { + const value = JSON.parse(source); + let index = 0; + + function skipWhitespace() { + while (/\s/.test(source[index] ?? "")) index += 1; + } + + function scanString() { + const start = index; + index += 1; + while (source[index] !== '"') { + if (source[index] === "\\") index += 1; + index += 1; + } + index += 1; + return JSON.parse(source.slice(start, index)); + } + + function scanValue() { + skipWhitespace(); + if (source[index] === "{") scanObject(); + else if (source[index] === "[") scanArray(); + else if (source[index] === '"') scanString(); + else while (index < source.length && !/[\s,}\]]/.test(source[index])) index += 1; + skipWhitespace(); + } + + function scanArray() { + index += 1; + skipWhitespace(); + if (source[index] === "]") { + index += 1; + return; + } + while (index < source.length) { + scanValue(); + if (source[index] === "]") { + index += 1; + return; + } + index += 1; + } + } + + function scanObject() { + const keys = new Set(); + index += 1; + skipWhitespace(); + if (source[index] === "}") { + index += 1; + return; + } + while (index < source.length) { + const key = scanString(); + if (keys.has(key)) throw new SyntaxError(`duplicate JSON property ${key}`); + keys.add(key); + skipWhitespace(); + index += 1; + scanValue(); + if (source[index] === "}") { + index += 1; + return; + } + index += 1; + skipWhitespace(); + } + } + + scanValue(); + return value; +} diff --git a/scripts/summarize-sentinel-calibration.mjs b/scripts/summarize-sentinel-calibration.mjs new file mode 100644 index 0000000..b144f6b --- /dev/null +++ b/scripts/summarize-sentinel-calibration.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { BASELINE_ENVIRONMENT, BASELINE_REFERENCE, finding, sha256, validateCalibration } from "./baseline-contract.mjs"; +import { parseCalibrationRun } from "./calibration-raw-contract.mjs"; + +const options = { artifactName: "", checkoutSha: "", executionRepository: "", headSha: "", input: "", json: false, output: "", repository: "", runAttempt: "", runId: "", sha: "", workflow: "" }; +const valueOptions = new Map([ + ["--artifact-name", "artifactName"], ["--checkout-sha", "checkoutSha"], ["--execution-repository", "executionRepository"], ["--head-sha", "headSha"], + ["--input", "input"], ["--output", "output"], ["--repository", "repository"], + ["--run-attempt", "runAttempt"], ["--run-id", "runId"], ["--sha", "sha"], ["--workflow", "workflow"], +]); +const failures = []; +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") options.json = true; + else if (valueOptions.has(argument)) { + const value = process.argv[index + 1]; + if (!value) failures.push(finding("argument_value_required", "", `${argument} requires a value`)); + else { + options[valueOptions.get(argument)] = value; + index += 1; + } + } else failures.push(finding("argument_unknown", "", `unsupported argument ${argument}`)); +} + +if (!options.input || !options.output) failures.push(finding("calibration_path_required", "", "--input and --output are required")); +const inputIsDirectory = Boolean(options.input && fs.existsSync(options.input) && !fs.lstatSync(options.input).isSymbolicLink() && fs.lstatSync(options.input).isDirectory()); +if (options.input && fs.existsSync(options.input) && !inputIsDirectory) failures.push(finding("calibration_input_invalid", options.input, "input must be a regular raw calibration directory, not a file or symlink")); +const expectedOutput = options.input ? path.join(path.resolve(options.input), "calibration.json") : ""; +if (options.output && path.resolve(options.output) !== expectedOutput) failures.push(finding("calibration_output_route", options.output, "output must be calibration.json directly inside the raw input directory")); +if (inputIsDirectory && options.output && path.resolve(options.output) === expectedOutput && fs.existsSync(options.output)) { + if (fs.lstatSync(options.output).isDirectory()) failures.push(finding("calibration_output_invalid", options.output, "output path must not be a directory")); + else fs.rmSync(options.output); +} +const runs = []; +if (options.input && fs.existsSync(options.input)) { + if (inputIsDirectory) { + const directories = fs.readdirSync(options.input, { withFileTypes: true }); + const expectedDirectories = Array.from({ length: 20 }, (_, index) => `run-${String(index + 1).padStart(2, "0")}`); + for (const directory of directories.filter((entry) => !entry.isDirectory() || !expectedDirectories.includes(entry.name))) { + failures.push(finding("calibration_run_directory_unknown", path.join(options.input, directory.name), "raw input may contain only run-01 through run-20 directories")); + } + for (let run = 1; run <= 20; run += 1) { + const name = expectedDirectories[run - 1]; + const root = path.join(options.input, name); + if (!fs.existsSync(root)) failures.push(finding("calibration_run_directory_missing", root, `${name} is required`)); + else if (fs.lstatSync(root).isSymbolicLink() || !fs.lstatSync(root).isDirectory() || !fs.realpathSync(root).startsWith(`${fs.realpathSync(options.input)}${path.sep}`)) failures.push(finding("calibration_run_directory_invalid", root, `${name} must be a contained regular directory, not a file or symlink`)); + else { + const parsed = parseCalibrationRun(root, run, failures); + if (parsed) runs.push(parsed); + } + } + } +} else if (options.input) failures.push(finding("calibration_input_missing", options.input, "input artifact directory does not exist")); + +const rawEvidence = failures.length === 0 ? sha256(Array.from({ length: 20 }, (_, index) => path.join(options.input, `run-${String(index + 1).padStart(2, "0")}`)) + .flatMap((root) => fs.readdirSync(root).sort().map((name) => { + const file = path.join(root, name); + return `${path.relative(options.input, file)}:${sha256(fs.readFileSync(file))}`; + })).join("\n")) : ""; + +const record = { + baseline_owner_approval: "pending", + committed_ci: { + artifact_name: options.artifactName, + checkout_sha: options.checkoutSha, + execution_repository: options.executionRepository, + external_verification: null, + head_sha: options.headSha, + raw_evidence_sha256: rawEvidence, + repository: options.repository, + run_attempt: options.runAttempt, + run_id: options.runId, + sha: options.sha, + workflow: options.workflow, + }, + environment: BASELINE_ENVIRONMENT, + required_runs: 20, + reference: BASELINE_REFERENCE, + runs, + schema_version: "1.0", + status: "awaiting_external_verification", +}; +failures.push(...validateCalibration(record, options.output || "")); +if (failures.length === 0) { + const temporaryOutput = `${options.output}.tmp`; + fs.writeFileSync(temporaryOutput, `${JSON.stringify(record, null, 2)}\n`); + fs.renameSync(temporaryOutput, options.output); +} +const diffs = runs.map((run) => run.screenshot_diff_pixels).sort((left, right) => left - right); +const stats = diffs.length === 20 ? { max: diffs[19], mean: diffs.reduce((sum, value) => sum + value, 0) / 20, min: diffs[0], p95: diffs[18] } : null; +const uniqueFailures = [...new Map(failures.map((failure) => [`${failure.code}:${failure.path}:${failure.message}`, failure])).values()]; +const result = { failures: uniqueFailures, ok: uniqueFailures.length === 0, runs: runs.length, stats, status: uniqueFailures.length === 0 ? "awaiting_external_verification" : "incomplete", warnings: [] }; +if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +else if (!result.ok) process.stderr.write(`${result.failures.map((failure) => `${failure.code}: ${failure.message}`).join("\n")}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/scripts/test-audit-evidence-freshness.mjs b/scripts/test-audit-evidence-freshness.mjs new file mode 100644 index 0000000..3be4d26 --- /dev/null +++ b/scripts/test-audit-evidence-freshness.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { cleanupFixture, completeSession, initializeConsumer } from "./page-evidence-fixture.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const auditor = path.join(repositoryRoot, "scripts", "audit-evidence-freshness.mjs"); +const calibration = path.join(repositoryRoot, "consumer-reference", "baselines", "calibration.json"); +const selectedCase = process.argv.find((argument) => argument === "--case") ? process.argv[process.argv.indexOf("--case") + 1] : null; +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-freshness-")); + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function run(args) { + const child = spawnSync(process.execPath, [auditor, ...args, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + let report; + try { + report = JSON.parse(child.stdout); + } catch (error) { + report = null; + } + return { child, report }; +} + +function result(name, expected, actual, ok) { + return { actual, expected, name, ok }; +} + +function pageSchema() { + return { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://stylegallery.test/page-evidence-manifest.schema.json", + additionalProperties: false, + properties: { + record_kind: { const: "page_evidence_manifest" }, + review_by: { format: "date-time", type: "string" }, + schema_version: { const: "1.0" }, + }, + required: ["record_kind", "review_by", "schema_version"], + type: "object", + }; +} + +function pageFixture(file, reviewBy) { + writeJson(file, { record_kind: "page_evidence_manifest", review_by: reviewBy, schema_version: "1.0" }); +} + +function selected(cases) { + if (!selectedCase) return cases; + const match = cases.filter((testCase) => testCase.name === selectedCase); + if (match.length === 0) { + return [result(selectedCase, "known --case", { cases: cases.map((testCase) => testCase.name) }, false)]; + } + return match; +} + +const pageSchemaPath = path.join(tempRoot, "page-evidence-manifest.schema.json"); +writeJson(pageSchemaPath, pageSchema()); +const pagePreDeadline = path.join(tempRoot, "review-window.json"); +pageFixture(pagePreDeadline, "2026-07-27T00:00:00Z"); +const pagePastDeadline = path.join(tempRoot, "review-past-deadline.json"); +pageFixture(pagePastDeadline, "2026-07-20T00:00:00Z"); +const pageAtDeadline = path.join(tempRoot, "review-at-deadline.json"); +pageFixture(pageAtDeadline, "2026-07-21T00:00:00Z"); +const pageFresh = path.join(tempRoot, "review-fresh.json"); +pageFixture(pageFresh, "2026-08-20T00:00:00Z"); +const malformed = path.join(tempRoot, "malformed.json"); +fs.writeFileSync(malformed, '{"record_kind":"page_evidence_manifest","review_by":"not-a-date","schema_version":"1.0"}\n'); +const unknown = path.join(tempRoot, "unknown.json"); +writeJson(unknown, { record_kind: "unsupported_evidence_manifest", schema_version: "1.0" }); +const missing = path.join(tempRoot, "missing.json"); +const missingPageSchema = path.join(tempRoot, "missing-page-evidence-manifest.schema.json"); +const completedPageFixture = initializeConsumer(); +const completedPage = completeSession(completedPageFixture); +if (completedPage.validate?.status !== 0) throw new Error(`full page fixture failed: ${JSON.stringify(completedPage.validate?.report ?? completedPage.finalize?.report)}`); +const completeManifest = JSON.parse(fs.readFileSync(path.join(completedPage.session.artifactRoot, "page-evidence-manifest.json"), "utf8")); +const impossibleDateRecords = [ + ["impossible-session-started-at", (value) => { value.session.started_at = "2023-02-30T12:00:00Z"; }], + ["impossible-session-completed-at", (value) => { value.session.completed_at = "2023-02-30T12:00:00Z"; }], + ["impossible-manifest-completed-at", (value) => { value.completed_at = "2023-02-30T12:00:00Z"; }], + ["impossible-scenario-recorded-at", (value) => { value.scenarios[0].recorded_at = "2023-02-30T12:00:00Z"; }], +].map(([name, mutate]) => { + const value = structuredClone(completeManifest); + mutate(value); + const file = path.join(tempRoot, `${name}.json`); + writeJson(file, value); + return { file, name }; +}); + +const cases = [ + (() => { + const actual = run(["--record", pageFresh, "--page-schema", pageSchemaPath, "--as-of", "2023-02-30T12:00:00Z", "--mode", "advisory"]); + return result("impossible-as-of", "exit nonzero with as_of_invalid", { codes: actual.report?.failures?.map((item) => item.code), status: actual.child.status }, actual.child.status !== 0 && actual.report?.failures?.some((item) => item.code === "as_of_invalid")); + })(), + ...impossibleDateRecords.map(({ file, name }) => (() => { + const actual = run(["--record", file, "--as-of", "2026-07-21T00:00:00Z", "--mode", "advisory"]); + return result(name, "exit nonzero with freshness_record_invalid", { codes: actual.report?.failures?.map((item) => item.code), status: actual.child.status }, actual.child.status !== 0 && actual.report?.failures?.some((item) => item.code === "freshness_record_invalid")); + })()), + (() => { + const actual = run(["--record", calibration, "--as-of", "2026-07-01T00:00:00Z", "--mode", "advisory"]); + return result("fresh-calibration", "exit 0 with status fresh and no warnings", { codes: actual.report?.failures?.map((item) => item.code), status: actual.child.status, statuses: actual.report?.records?.map((item) => item.status) }, actual.child.status === 0 && actual.report?.ok === true && actual.report.records[0]?.status === "fresh" && actual.report.warnings.length === 0); + })(), + (() => { + const actual = run(["--record", calibration, "--as-of", "2026-07-21T00:00:00Z", "--mode", "advisory"]); + return result("expiring-calibration-advisory", "exit 0 with expiring status and evidence_expiring warning", { codes: actual.report?.warnings?.map((item) => item.code), status: actual.child.status, statuses: actual.report?.records?.map((item) => item.status) }, actual.child.status === 0 && actual.report?.ok === true && actual.report.records[0]?.status === "expiring" && actual.report.warnings.some((item) => item.code === "evidence_expiring")); + })(), + (() => { + const actual = run(["--record", calibration, "--as-of", "2026-07-28T00:00:00Z", "--mode", "advisory"]); + return result("expired-calibration-advisory", "exit 0 with expired status and warning", { codes: actual.report?.warnings?.map((item) => item.code), status: actual.child.status, statuses: actual.report?.records?.map((item) => item.status) }, actual.child.status === 0 && actual.report?.ok === true && actual.report.records[0]?.status === "expired" && actual.report.warnings.some((item) => item.code === "evidence_expired")); + })(), + (() => { + const actual = run(["--record", pagePreDeadline, "--page-schema", pageSchemaPath, "--as-of", "2026-07-21T00:00:00Z", "--mode", "advisory"]); + return result("pre-deadline-page-advisory", "exit 0 with expiring status and evidence_expiring warning", { codes: actual.report?.warnings?.map((item) => item.code), status: actual.child.status, statuses: actual.report?.records?.map((item) => item.status) }, actual.child.status === 0 && actual.report?.ok === true && actual.report.records[0]?.status === "expiring" && actual.report.warnings.some((item) => item.code === "evidence_expiring")); + })(), + (() => { + const actual = run(["--record", pagePreDeadline, "--page-schema", pageSchemaPath, "--as-of", "2026-07-21T00:00:00Z", "--mode", "blocking"]); + return result("pre-deadline-review-window-blocking", "exit 0 with expiring status, advisory evidence_expiring warning, and no blocking failure", { failureCodes: actual.report?.failures?.map((item) => item.code), warningCodes: actual.report?.warnings?.map((item) => item.code), status: actual.child.status, statuses: actual.report?.records?.map((item) => item.status) }, actual.child.status === 0 && actual.report?.ok === true && actual.report.records[0]?.status === "expiring" && actual.report.warnings.some((item) => item.code === "evidence_expiring") && actual.report.failures.length === 0); + })(), + (() => { + const actual = run(["--record", pagePastDeadline, "--page-schema", pageSchemaPath, "--as-of", "2026-07-21T00:00:00Z", "--mode", "advisory"]); + return result("review-due-page-advisory", "exit 0 with review_due status and evidence_review_due warning", { codes: actual.report?.warnings?.map((item) => item.code), status: actual.child.status, statuses: actual.report?.records?.map((item) => item.status) }, actual.child.status === 0 && actual.report?.ok === true && actual.report.records[0]?.status === "review_due" && actual.report.warnings.some((item) => item.code === "evidence_review_due")); + })(), + (() => { + const before = fs.readFileSync(calibration); + const actual = run(["--record", calibration, "--record", pagePreDeadline, "--page-schema", pageSchemaPath, "--as-of", "2026-07-21T00:00:00Z", "--mode", "advisory"]); + const after = fs.readFileSync(calibration); + return result("repeated-record-and-source-immutability", "two records are audited and calibration bytes remain unchanged", { bytesUnchanged: before.equals(after), count: actual.report?.records?.length, status: actual.child.status }, actual.child.status === 0 && actual.report?.records?.length === 2 && before.equals(after)); + })(), + (() => { + const actual = run(["--record", pageFresh, "--page-schema", pageSchemaPath, "--as-of", "2026-07-21T00:00:00Z", "--mode", "advisory"]); + return result("fresh-page-review", "exit 0 with fresh page evidence", { status: actual.child.status, statuses: actual.report?.records?.map((item) => item.status) }, actual.child.status === 0 && actual.report?.records[0]?.status === "fresh"); + })(), + (() => { + const actual = run(["--record", malformed, "--page-schema", pageSchemaPath, "--as-of", "2026-07-21T00:00:00Z", "--mode", "advisory"]); + return result("malformed-advisory", "exit nonzero with freshness_record_invalid", { codes: actual.report?.failures?.map((item) => item.code), status: actual.child.status }, actual.child.status !== 0 && actual.report?.ok === false && actual.report.failures.some((item) => item.code === "freshness_record_invalid")); + })(), + (() => { + const actual = run(["--record", unknown, "--as-of", "2026-07-21T00:00:00Z", "--mode", "advisory"]); + return result("unknown-record-kind", "exit nonzero with freshness_record_invalid", { codes: actual.report?.failures?.map((item) => item.code), status: actual.child.status }, actual.child.status !== 0 && actual.report?.failures.some((item) => item.code === "freshness_record_invalid")); + })(), + (() => { + const actual = run(["--record", missing, "--as-of", "2026-07-21T00:00:00Z", "--mode", "advisory"]); + return result("unreadable-record", "exit nonzero with freshness_record_unreadable", { codes: actual.report?.failures?.map((item) => item.code), status: actual.child.status }, actual.child.status !== 0 && actual.report?.failures.some((item) => item.code === "freshness_record_unreadable")); + })(), + (() => { + const actual = run(["--record", pagePreDeadline, "--page-schema", missingPageSchema, "--as-of", "2026-07-21T00:00:00Z", "--mode", "advisory"]); + return result("missing-page-schema", "exit nonzero with freshness_schema_missing", { codes: actual.report?.failures?.map((item) => item.code), status: actual.child.status }, actual.child.status !== 0 && actual.report?.failures.some((item) => item.code === "freshness_schema_missing")); + })(), + (() => { + const actual = run(["--record", calibration, "--as-of", "2026-07-28T00:00:00Z", "--mode", "blocking"]); + return result("expired-calibration-blocking", "exit nonzero with evidence_expired", { codes: actual.report?.failures?.map((item) => item.code), status: actual.child.status }, actual.child.status !== 0 && actual.report?.failures.some((item) => item.code === "evidence_expired")); + })(), + (() => { + const actual = run(["--record", pagePastDeadline, "--page-schema", pageSchemaPath, "--as-of", "2026-07-21T00:00:00Z", "--mode", "blocking"]); + return result("review-due-blocking", "exit nonzero with evidence_review_due", { codes: actual.report?.failures?.map((item) => item.code), status: actual.child.status, statuses: actual.report?.records?.map((item) => item.status) }, actual.child.status !== 0 && actual.report?.ok === false && actual.report.records[0]?.status === "review_due" && actual.report.failures.some((item) => item.code === "evidence_review_due")); + })(), + (() => { + const actual = run(["--record", pageAtDeadline, "--page-schema", pageSchemaPath, "--as-of", "2026-07-21T00:00:00Z", "--mode", "blocking"]); + return result("page-at-review-deadline-blocking", "exit nonzero with review_due/evidence_review_due at the deadline", { codes: actual.report?.failures?.map((item) => item.code), status: actual.child.status, statuses: actual.report?.records?.map((item) => item.status) }, actual.child.status !== 0 && actual.report?.ok === false && actual.report.records[0]?.status === "review_due" && actual.report.failures.some((item) => item.code === "evidence_review_due")); + })(), +]; + +try { + const report = { ok: selected(cases).every((testCase) => testCase.ok), results: selected(cases) }; + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.exitCode = report.ok ? 0 : 1; +} finally { + cleanupFixture(completedPageFixture); + fs.rmSync(tempRoot, { force: true, recursive: true }); +} diff --git a/scripts/test-component-state-sentinel.mjs b/scripts/test-component-state-sentinel.mjs new file mode 100644 index 0000000..8ef89e6 --- /dev/null +++ b/scripts/test-component-state-sentinel.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const cases = [ + ["key_runtime_mismatch", "runtime-key-mismatch", "state_activation_key_parity", "action-focused"], + ["pressed_false", "runtime-pressed-false", "pressed_surface_mismatch", "toggle-focused-pressed"], + ["visual_ready", "runtime-visual-ready", "visual_surface_parity", "toggle-focused-pressed"], + ["unknown_scenario", "runtime-unknown-scenario", "state_runtime_unknown_scenario", "action-focused"], + ["expanded_ax_mismatch", "runtime-expanded-ax-mismatch", "expanded_surface_mismatch", "disclosure-expanded-loading"], + ["disabled_activation", "runtime-disabled-activation", "state_activation_key_parity", "action-disabled-busy"], +]; + +const results = cases.map(([name, mutation, expected, scenario]) => { + const child = spawnSync( + path.join(repositoryRoot, "node_modules", ".bin", "playwright"), + ["test", "tests/component-state-evidence.spec.mjs", "--project=chromium", "--reporter=line", "--grep", scenario], + { cwd: repositoryRoot, encoding: "utf8", env: { ...process.env, STATE_MUTATION: mutation } }, + ); + const output = `${child.stdout}\n${child.stderr}`; + return { + actual: { named: output.includes(expected), status: child.status }, + expected, + name, + ok: child.status !== 0 && output.includes(expected), + }; +}); + +const failures = results.filter((result) => !result.ok).map((result) => `missing_semantic:${result.name}:${result.expected}`); +const report = { failures, ok: failures.length === 0, results }; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +if (!report.ok) process.exitCode = 1; diff --git a/scripts/test-component-state-source-contract.mjs b/scripts/test-component-state-source-contract.mjs new file mode 100644 index 0000000..ecbfc79 --- /dev/null +++ b/scripts/test-component-state-source-contract.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + canonicalSourceManifest, + repositoryGitArgs, + sourceManifestMatches, +} from "./capture-session-contract.mjs"; +import { compileSchemas } from "./component-state-contract.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const profileRoot = path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local"); +const schemaRoot = path.join(repositoryRoot, "consumer-reference/schema"); +const first = canonicalSourceManifest(repositoryRoot, profileRoot); +const second = canonicalSourceManifest(repositoryRoot, profileRoot); + +assert.equal(first.sha256, second.sha256, "source manifest digest must be deterministic"); +assert.deepEqual(first.files, second.files, "source manifest files must be stable"); +assert(first.files.some((entry) => entry.path === "tests/component-state-evidence.spec.mjs"), "capture browser code must be bound"); +assert(first.files.some((entry) => entry.path === "playwright.config.mjs"), "Playwright capture configuration must be bound"); +assert(first.files.some((entry) => entry.path === "package.json"), "capture dependency declarations must be bound"); +assert(first.files.some((entry) => entry.path === "package-lock.json"), "exact capture dependencies must be bound"); +assert(first.files.some((entry) => entry.path === "scripts/artifact-metadata.mjs"), "capture artifact metadata code must be bound"); +assert(first.files.some((entry) => entry.path === "scripts/visual-expectation-contract.mjs"), "visual expectation selection must be bound"); +assert(!first.files.some((entry) => entry.path === "scripts/finalize-component-state-evidence.mjs"), "post-capture finalization must not invalidate captured source identity"); +assert(!first.files.some((entry) => entry.path === "scripts/validate-component-state.mjs"), "post-capture enforcement must not invalidate captured source identity"); +assert(first.files.some((entry) => entry.path.endsWith("/profile.json")), "profile declarations must be bound"); +assert(first.files.some((entry) => entry.path.endsWith("/tokens.dtcg.json")), "profile token inputs must be bound"); +assert.equal(sourceManifestMatches(first, repositoryRoot, profileRoot), true, "canonical source manifest must match itself"); +const revisionArgs = repositoryGitArgs(repositoryRoot, "rev-parse", "HEAD"); +assert.deepEqual(revisionArgs, ["-c", `safe.directory=${repositoryRoot}`, "rev-parse", "HEAD"], "repository Git trust must be scoped to the exact checkout"); +assert.equal( + execFileSync("git", revisionArgs, { + cwd: repositoryRoot, + encoding: "utf8", + env: { ...process.env, GIT_TEST_ASSUME_DIFFERENT_OWNER: "1" }, + }).trim(), + execFileSync("git", revisionArgs, { cwd: repositoryRoot, encoding: "utf8" }).trim(), + "repository Git reads must trust only the exact checkout when container ownership differs", +); + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-source-contract-")); +try { + const copiedProfiles = path.join(tempRoot, "profiles"); + const copiedRepository = path.join(tempRoot, "repository"); + fs.cpSync(profileRoot, copiedProfiles, { recursive: true }); + for (const entry of first.files.filter((candidate) => !candidate.path.startsWith("profiles/"))) { + const source = path.join(repositoryRoot, entry.path); + const target = path.join(copiedRepository, entry.path); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(source, target); + } + const copied = canonicalSourceManifest(copiedRepository, copiedProfiles); + assert.equal(copied.sha256, first.sha256, "profile-root location must not change the source digest"); + const tokenFile = path.join(copiedProfiles, "editorial/tokens.dtcg.json"); + fs.appendFileSync(tokenFile, "\n"); + assert.equal(sourceManifestMatches(copied, copiedRepository, copiedProfiles), false, "profile source drift must invalidate the manifest"); + fs.copyFileSync(path.join(profileRoot, "editorial/tokens.dtcg.json"), tokenFile); + const configFile = path.join(copiedRepository, "playwright.config.mjs"); + const config = fs.readFileSync(configFile, "utf8").replace("viewport: { height: 768, width: 1024 },", "deviceScaleFactor: 2,\n viewport: { height: 768, width: 1024 },"); + fs.writeFileSync(configFile, config); + assert.equal(sourceManifestMatches(copied, copiedRepository, copiedProfiles), false, "deviceScaleFactor drift must invalidate the source manifest before it can double screenshot pixels"); + + const copiedSchemas = path.join(tempRoot, "schemas"); + fs.cpSync(schemaRoot, copiedSchemas, { recursive: true }); + const captureSchema = path.join(copiedSchemas, "capture-session.schema.json"); + const schemaSource = fs.readFileSync(captureSchema, "utf8"); + fs.writeFileSync(captureSchema, schemaSource.replace('"title":', '"title": "shadowed title",\n "title":')); + assert.throws( + () => compileSchemas(copiedSchemas), + (error) => error?.code === "component_schema_json_invalid" && error?.path === captureSchema, + "duplicate component schema properties must fail strict compilation with a named error", + ); +} finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); +} + +process.stdout.write(`${JSON.stringify({ files: first.files.length, ok: true, sha256: first.sha256 }, null, 2)}\n`); diff --git a/scripts/test-consumer-conformance-sentinel.mjs b/scripts/test-consumer-conformance-sentinel.mjs new file mode 100644 index 0000000..f4e0a30 --- /dev/null +++ b/scripts/test-consumer-conformance-sentinel.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const availableCases = new Map([ + ["overflow", { expected: "document_no_horizontal_overflow", grep: "@sentinel-overflow", mutation: "overflow" }], + ["clipped-focus", { expected: "focus_geometry_visible", grep: "@sentinel-clipped-focus", mutation: "clipped-focus" }], + ["low-contrast", { expected: "color-contrast", grep: "@sentinel-low-contrast", mutation: "low-contrast" }], + ["dialog-focus-leak", { expected: "dialog_focus_trap", grep: "@sentinel-dialog-focus-leak", mutation: "dialog-focus-leak" }], + ["console-error", { expected: "console_error_free", grep: "@sentinel-console-error", mutation: "console-error" }], + ["page-evidence-symlink", { expected: "page_evidence_output_symlink", pageEvidence: "symlink" }], + ["page-evidence-existing-output", { expected: "page_evidence_output_exists", pageEvidence: "existing-output" }], + ["ci-cleanup-symlink", { ciCleanup: true, expected: ".tmp must be a real directory" }], +]); +const selected = []; +let json = false; +const argumentFailures = []; + +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") json = true; + else if (argument === "--case") { + const name = process.argv[index + 1]; + if (!name || name.startsWith("--")) argumentFailures.push("--case requires a value"); + else { + selected.push(name); + index += 1; + } + } else argumentFailures.push(`unsupported argument ${argument}`); +} + +const names = selected.length > 0 ? [...new Set(selected)] : [...availableCases.keys()]; +for (const name of names) if (!availableCases.has(name)) argumentFailures.push(`unknown case ${name}`); + +function runPlaywright(grep, environment) { + return spawnSync( + path.join(repositoryRoot, "node_modules", ".bin", "playwright"), + ["test", "tests/consumer-conformance.spec.mjs", "--project=chromium", "--reporter=line", "--grep", grep], + { + cwd: repositoryRoot, + encoding: "utf8", + env: { ...process.env, ...environment }, + maxBuffer: 4 * 1024 * 1024, + }, + ); +} + +function runMutationControl(name, control) { + const child = runPlaywright(control.grep, { CONSUMER_CONFORMANCE_MUTATION: control.mutation }); + const output = `${child.stdout}\n${child.stderr}`; + return { + actual: { named: output.includes(control.expected), status: child.status }, + expected: control.expected, + name, + ok: child.status !== 0 && output.includes(control.expected), + }; +} + +function writeReceipt(root) { + const receipt = { + attempt: 1, + intended_scenario_ids: ["responsive-layout"], + nonce: "a".repeat(64), + repository: "changeroa/StyleGallery", + revision: "b".repeat(40), + run_id: "consumer-conformance-sentinel-run", + session_id: "consumer-conformance-sentinel-session", + source: { sha256: "c".repeat(64) }, + }; + fs.writeFileSync(path.join(root, "page-evidence-session.json"), `${JSON.stringify(receipt)}\n`, { flag: "wx" }); +} + +function runPageEvidenceControl(name, control) { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-page-evidence-sentinel-")); + const observations = []; + try { + for (const outputKind of ["captures", "runner"]) { + const caseRoot = path.join(temporaryRoot, outputKind); + const artifactRoot = path.join(caseRoot, "artifacts"); + const outside = path.join(caseRoot, "outside"); + fs.mkdirSync(artifactRoot, { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + writeReceipt(artifactRoot); + + const filename = outputKind === "captures" + ? "responsive-layout-state-w1024-focus.png" + : "responsive-layout.json"; + const outputPath = path.join(artifactRoot, outputKind, filename); + const outsidePath = path.join(outside, filename); + const marker = "do-not-overwrite"; + if (control.pageEvidence === "symlink") fs.symlinkSync(outside, path.join(artifactRoot, outputKind), "dir"); + else { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, marker, { flag: "wx" }); + } + + const child = runPlaywright("state-w1024-focus", { + CONSUMER_CONFORMANCE_CAPTURE_DIR: "", + CONSUMER_CONFORMANCE_MUTATION: "none", + PAGE_EVIDENCE_ARTIFACT_ROOT: artifactRoot, + PAGE_EVIDENCE_CASE_ID: "state-w1024-focus", + PAGE_EVIDENCE_SCENARIO_ID: "responsive-layout", + }); + const output = `${child.stdout}\n${child.stderr}`; + observations.push({ + named: output.includes(control.expected), + output_kind: outputKind, + outside_write: fs.existsSync(outsidePath), + preserved: control.pageEvidence === "existing-output" ? fs.readFileSync(outputPath, "utf8") === marker : true, + status: child.status, + }); + } + } finally { + fs.rmSync(temporaryRoot, { force: true, recursive: true }); + } + + return { + actual: { observations }, + expected: control.expected, + name, + ok: observations.every((observation) => observation.status !== 0 && observation.named && !observation.outside_write && observation.preserved), + }; +} + +function runCiCleanupControl(name, control) { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-ci-cleanup-sentinel-")); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-ci-cleanup-outside-")); + const marker = path.join(outside, "consumer", "preserve.txt"); + try { + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, "preserve\n", { flag: "wx" }); + fs.symlinkSync(outside, path.join(workspace, ".tmp"), "dir"); + const child = spawnSync(process.execPath, [path.join(repositoryRoot, "scripts", "run-consumer-page-evidence-ci.mjs"), "cleanup", "--consumer-root", ".tmp/consumer"], { cwd: workspace, encoding: "utf8" }); + const output = `${child.stdout}\n${child.stderr}`; + return { + actual: { markerPreserved: fs.readFileSync(marker, "utf8") === "preserve\n", named: output.includes(control.expected), status: child.status }, + expected: control.expected, + name, + ok: child.status !== 0 && output.includes(control.expected) && fs.readFileSync(marker, "utf8") === "preserve\n", + }; + } finally { + fs.rmSync(workspace, { force: true, recursive: true }); + fs.rmSync(outside, { force: true, recursive: true }); + } +} + +const results = argumentFailures.length === 0 ? names.map((name) => { + const control = availableCases.get(name); + return control.pageEvidence ? runPageEvidenceControl(name, control) : control.ciCleanup ? runCiCleanupControl(name, control) : runMutationControl(name, control); +}) : []; + +const failures = [ + ...argumentFailures.map((message) => `argument_invalid:${message}`), + ...results.filter((result) => !result.ok).map((result) => `missing_semantic:${result.name}:${result.expected}`), +]; +const report = { failures, ok: failures.length === 0, results }; +if (json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +else if (report.ok) process.stdout.write(`consumer conformance sentinel passed ${results.length} mutation controls\n`); +else process.stderr.write(`${failures.join("\n")}\n`); +if (!report.ok) process.exitCode = 1; diff --git a/scripts/test-consumer-reference-sentinel.mjs b/scripts/test-consumer-reference-sentinel.mjs new file mode 100644 index 0000000..d151a77 --- /dev/null +++ b/scripts/test-consumer-reference-sentinel.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const cases = [ + ["hidden_layout", "hidden-layout", "computed_layout_visible"], + ["long_content_reflow", "long-content-reflow", "computed_long_content_reflow"], +]; + +const results = cases.map(([name, mutation, expected]) => { + const child = spawnSync( + path.join(repositoryRoot, "node_modules", ".bin", "playwright"), + ["test", "tests/consumer-reference-sentinels.spec.mjs", "--project=chromium", "--reporter=line"], + { cwd: repositoryRoot, encoding: "utf8", env: { ...process.env, SENTINEL_MUTATION: mutation } }, + ); + const output = `${child.stdout}\n${child.stderr}`; + return { + actual: { named: output.includes(expected), status: child.status }, + expected, + name, + ok: child.status !== 0 && output.includes(expected), + }; +}); + +const failures = results.filter((result) => !result.ok).map((result) => `missing_semantic:${result.name}:${result.expected}`); +const report = { failures, ok: failures.length === 0, results }; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +if (!report.ok) process.exitCode = 1; diff --git a/scripts/test-generate-consumer-reference-evidence.mjs b/scripts/test-generate-consumer-reference-evidence.mjs new file mode 100644 index 0000000..d85c79c --- /dev/null +++ b/scripts/test-generate-consumer-reference-evidence.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoot = path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local"); +const generator = path.join(repositoryRoot, "scripts/generate-consumer-reference-evidence.mjs"); + +function run(root, check = false, environment = {}) { + const args = [generator, "--root", root, "--json"]; + if (check) args.push("--check"); + const child = spawnSync(process.execPath, args, { cwd: repositoryRoot, encoding: "utf8", env: { ...process.env, ...environment } }); + let report = { failures: [], ok: false, parse_error: true }; + try { report = JSON.parse(child.stdout); } catch { report = { failures: [], ok: false, parse_error: true }; } + return { report, status: child.status }; +} + +function editJson(file, mutate) { + const value = JSON.parse(fs.readFileSync(file, "utf8")); + mutate(value); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-pr5-generator-")); +const results = []; + +function freshRoot(name) { + const root = path.join(tempRoot, name); + fs.cpSync(sourceRoot, root, { recursive: true }); + return root; +} + +function expectFailure(name, expected, root) { + const child = run(root, true); + results.push({ expected, name, ok: child.status > 0 && child.report.failures.some((failure) => failure.code === expected) }); +} + +function mutateGenerated(name, expected, mutate) { + const root = freshRoot(name); + run(root); + const file = path.join(root, "editorial/generated/state-matrix.md"); + const lines = fs.readFileSync(file, "utf8").split("\n"); + mutate(lines); + fs.writeFileSync(file, lines.join("\n")); + expectFailure(name, expected, root); +} + +try { + const canonicalRoot = freshRoot("canonical"); + const generated = run(canonicalRoot); + results.push({ expected: "canonical_generation", name: "canonical", ok: generated.status === 0 && generated.report.ok && generated.report.scaffold !== true }); + const checked = run(canonicalRoot, true); + results.push({ expected: "deterministic_zero_drift", name: "check", ok: checked.status === 0 && checked.report.ok && checked.report.scaffold !== true }); + + mutateGenerated("stale", "generated_stale", (lines) => { lines.push("stale"); }); + mutateGenerated("generated_unsorted", "generated_unsorted", (lines) => { + const first = lines.findIndex((line) => line.startsWith("| ")); + [lines[first], lines[first + 1]] = [lines[first + 1], lines[first]]; + }); + mutateGenerated("generated_duplicate", "generated_duplicate_row", (lines) => { + const first = lines.findIndex((line) => line.startsWith("| ")); + lines.splice(first + 1, 0, lines[first]); + }); + mutateGenerated("generated_missing", "generated_missing_row", (lines) => { + const first = lines.findIndex((line) => line.startsWith("| ")); + lines.splice(first, 1); + }); + mutateGenerated("source_count", "generated_source_count_mismatch", (lines) => { + const first = lines.findIndex((line) => line.startsWith("| ")); + lines.splice(first, 1); + }); + + const unsortedRoot = freshRoot("source_unsorted"); + editJson(path.join(unsortedRoot, "editorial/states/button.states.json"), (value) => { value.scenarios.reverse(); }); + expectFailure("source_unsorted", "source_unsorted", unsortedRoot); + + const duplicateRoot = freshRoot("scenario_duplicate"); + editJson(path.join(duplicateRoot, "editorial/states/button.states.json"), (value) => { value.scenarios.push({ ...value.scenarios[0] }); }); + expectFailure("scenario_duplicate", "scenario_duplicate", duplicateRoot); + + const missingRoot = freshRoot("fixture_scenario_missing"); + editJson(path.join(missingRoot, "editorial/states/button.states.json"), (value) => { value.scenarios = value.scenarios.filter((scenario) => scenario.id !== "action-focused"); }); + expectFailure("fixture_scenario_missing", "fixture_scenario_missing", missingRoot); + + const injectionRoot = freshRoot("markdown_injection"); + editJson(path.join(injectionRoot, "editorial/components/button.component.json"), (value) => { value.semantic_modes[0].element = "|`\nnext"; }); + const injection = run(injectionRoot); + const injectionOutput = fs.readFileSync(path.join(injectionRoot, "editorial/generated/keyboard-matrix.md"), "utf8"); + results.push({ + expected: "escaped_markdown_injection", + name: "markdown_injection", + ok: injection.status === 0 + && injectionOutput.includes("<script>alert(1)</script>|`
next") + && !injectionOutput.includes(""), + }); + + const referenceRoot = freshRoot("canonical_reference_discovery"); + fs.copyFileSync(path.join(referenceRoot, "editorial/components/button.component.json"), path.join(referenceRoot, "editorial/components/alternate.component.json")); + editJson(path.join(referenceRoot, "editorial/components/alternate.component.json"), (value) => { value.semantic_modes[0].element = "canonical alternate element"; }); + editJson(path.join(referenceRoot, "editorial/profile.json"), (value) => { value.component_records = ["components/alternate.component.json"]; }); + editJson(path.join(referenceRoot, "editorial/fixtures/button.fixture.json"), (value) => { value.component_record = "../components/alternate.component.json"; }); + const reference = run(referenceRoot); + const referenceOutput = fs.readFileSync(path.join(referenceRoot, "editorial/generated/keyboard-matrix.md"), "utf8"); + results.push({ expected: "canonical_profile_reference", name: "canonical_reference_discovery", ok: reference.status === 0 && referenceOutput.includes("canonical alternate element") }); + + const localeRoot = freshRoot("locale_determinism"); + const firstLocale = run(localeRoot, false, { LANG: "C", LC_ALL: "C" }); + const firstBytes = fs.readFileSync(path.join(localeRoot, "editorial/generated/state-matrix.md")); + const secondLocale = run(localeRoot, false, { LANG: "tr_TR.UTF-8", LC_ALL: "tr_TR.UTF-8" }); + const secondBytes = fs.readFileSync(path.join(localeRoot, "editorial/generated/state-matrix.md")); + results.push({ expected: "locale_independent_bytes", name: "locale_determinism", ok: firstLocale.status === 0 && secondLocale.status === 0 && firstBytes.equals(secondBytes) }); +} finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + +const failures = results.filter((result) => !result.ok).map((result) => `missing_semantic:${result.name}:${result.expected}`); +const report = { failures, ok: failures.length === 0, results }; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +assert.equal(report.ok, true); diff --git a/scripts/test-json-cli-output.mjs b/scripts/test-json-cli-output.mjs new file mode 100644 index 0000000..117d4e5 --- /dev/null +++ b/scripts/test-json-cli-output.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const root = path.resolve(path.dirname(scriptPath), ".."); +const cases = [ + { kind: "validator", script: "validate-consumer-reference.mjs" }, + { kind: "harness", script: "test-validate-consumer-reference.mjs" }, +]; + +function reportIsComplete(kind, report) { + if (!report || typeof report !== "object" || report.ok !== true) return false; + if (kind === "validator") { + return Number.isInteger(report.checkedItems) + && report.checkedItems > 0 + && Array.isArray(report.profiles); + } + return kind === "harness" && Array.isArray(report.results) && report.results.length > 30; +} + +function readStream(stream) { + return new Promise((resolve, reject) => { + stream.setEncoding("utf8"); + let output = ""; + stream.on("data", (chunk) => { output += chunk; }); + stream.once("end", () => resolve(output)); + stream.once("error", reject); + }); +} + +function waitForClose(child, label) { + return new Promise((resolve, reject) => { + child.once("error", (error) => reject(new Error(`${label} failed to start: ${error.message}`, { cause: error }))); + child.once("close", (status, signal) => resolve({ signal, status })); + }); +} + +async function parsePipe(kind) { + try { + const output = await readStream(process.stdin); + const report = JSON.parse(output); + const complete = reportIsComplete(kind, report); + const summary = { + bytes: Buffer.byteLength(output), + complete, + digest: createHash("sha256").update(output).digest("hex"), + }; + process.stdout.write(`${JSON.stringify(summary)}\n`); + process.exitCode = complete ? 0 : 1; + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} + +async function runPipeCase(testCase, run) { + const producer = spawn(process.execPath, [path.join(root, "scripts", testCase.script), "--json"], { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + }); + const parser = spawn(process.execPath, [scriptPath, "--pipe-parser", testCase.kind], { + cwd: root, + stdio: ["pipe", "pipe", "pipe"], + }); + const directOutputPromise = readStream(producer.stdout); + const producerStderrPromise = readStream(producer.stderr); + const parserOutputPromise = readStream(parser.stdout); + const parserStderrPromise = readStream(parser.stderr); + let pipeError = null; + parser.stdin.once("error", (error) => { pipeError = error; }); + producer.stdout.pipe(parser.stdin); + + const [producerClose, parserClose, directOutput, producerStderr, parserOutput, parserStderr] = await Promise.all([ + waitForClose(producer, `${testCase.kind} producer`), + waitForClose(parser, `${testCase.kind} parser`), + directOutputPromise, + producerStderrPromise, + parserOutputPromise, + parserStderrPromise, + ]); + + let directReport; + let pipeReport; + try { + directReport = JSON.parse(directOutput); + pipeReport = JSON.parse(parserOutput); + } catch (error) { + return { + actual: { + parserStatus: parserClose.status, + parserStderr: parserStderr.trim(), + pipeError: pipeError?.message ?? "", + producerStatus: producerClose.status, + producerStderr: producerStderr.trim(), + }, + expected: "complete parseable JSON through a real Node stream pipe", + name: `${testCase.kind}_pipe_run_${run}`, + ok: false, + reason: error instanceof Error ? error.message : String(error), + }; + } + + const directBytes = Buffer.byteLength(directOutput); + const directDigest = createHash("sha256").update(directOutput).digest("hex"); + const equivalent = pipeReport.bytes === directBytes && pipeReport.digest === directDigest; + const complete = reportIsComplete(testCase.kind, directReport) && pipeReport.complete === true; + return { + actual: { + complete, + directBytes, + equivalent, + parserSignal: parserClose.signal, + parserStatus: parserClose.status, + parserStderr: parserStderr.trim(), + pipeBytes: pipeReport.bytes, + pipeError: pipeError?.message ?? "", + producerSignal: producerClose.signal, + producerStatus: producerClose.status, + producerStderr: producerStderr.trim(), + }, + expected: "complete parseable JSON through a real Node stream pipe", + name: `${testCase.kind}_pipe_run_${run}`, + ok: producerClose.status === 0 + && producerClose.signal === null + && parserClose.status === 0 + && parserClose.signal === null + && producerStderr.length === 0 + && parserStderr.length === 0 + && pipeError === null + && directBytes > 512 + && complete + && equivalent, + }; +} + +async function main() { + const results = []; + for (const testCase of cases) { + for (const run of [1, 2]) results.push(await runPipeCase(testCase, run)); + } + const report = { ok: results.every((result) => result.ok), results }; + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.exitCode = report.ok ? 0 : 1; +} + +if (process.argv[2] === "--pipe-parser") await parsePipe(process.argv[3]); +else await main(); diff --git a/scripts/test-json-schema-formats.mjs b/scripts/test-json-schema-formats.mjs new file mode 100644 index 0000000..6d7703c --- /dev/null +++ b/scripts/test-json-schema-formats.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import test from "node:test"; +import Ajv2020 from "ajv/dist/2020.js"; +import { addDateTimeFormat, isRfc3339DateTime } from "./json-schema-formats.mjs"; + +test("rejects RFC3339 values whose calendar date would normalize", () => { + for (const value of [ + "2023-02-29T12:00:00Z", + "2023-02-30T12:00:00Z", + "2024-04-31T12:00:00+05:30", + ]) { + assert.equal(isRfc3339DateTime(value), false, value); + } +}); + +test("preserves valid RFC3339 offsets and fractional seconds", () => { + for (const value of [ + "2024-02-29T23:59:59Z", + "2024-02-29T23:59:59.123456789+05:30", + "2024-02-29T00:00:00-04:00", + ]) { + assert.equal(isRfc3339DateTime(value), true, value); + } +}); + +test("uses the strict validator for the AJV date-time format", () => { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addDateTimeFormat(ajv); + const validate = ajv.compile({ + additionalProperties: false, + properties: { at: { format: "date-time", type: "string" } }, + required: ["at"], + type: "object", + }); + + assert.equal(validate({ at: "2024-02-29T23:59:59.123Z" }), true); + assert.equal(validate({ at: "2023-02-29T23:59:59.123Z" }), false); +}); diff --git a/scripts/test-reference-adapters.mjs b/scripts/test-reference-adapters.mjs new file mode 100644 index 0000000..1a012e3 --- /dev/null +++ b/scripts/test-reference-adapters.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const builder = path.join(repositoryRoot, "scripts", "build-reference-artifacts.mjs"); +const validator = path.join(repositoryRoot, "scripts", "validate-reference-artifacts.mjs"); +const schema = JSON.parse(fs.readFileSync(path.join(repositoryRoot, "consumer-reference/schema/portable-tokens.schema.json"), "utf8")); + +function parseJson(child) { + try { + return JSON.parse(child.stdout); + } catch (error) { + return { failures: [{ code: "invalid_json_output", message: error instanceof Error ? error.message : String(error) }], ok: false }; + } +} + +function codes(output) { + return Array.isArray(output.failures) + ? output.failures.flatMap((failure) => typeof failure?.code === "string" ? [failure.code] : []) + : []; +} + +const tempRoot = fs.mkdtempSync(path.join(repositoryRoot, ".tmp-stylegallery-reference-adapter-")); +const results = []; + +function run(command, args) { + const child = spawnSync(process.execPath, [command, ...args, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + return { child, output: parseJson(child) }; +} + +function recordAccepted(name, execution, predicate = () => true) { + const actualCodes = codes(execution.output); + const accepted = execution.child.status === 0 && execution.output.ok === true && execution.output.scaffold !== true; + results.push({ + actual: { codes: actualCodes, ok: execution.output.ok, scaffold: execution.output.scaffold === true, status: execution.child.status }, + expected: "ok:true and exit:0", + name, + ok: accepted && predicate(), + }); +} + +function recordRejected(name, execution, expectedCode) { + const actualCodes = codes(execution.output); + results.push({ + actual: { codes: actualCodes, ok: execution.output.ok, scaffold: execution.output.scaffold === true, status: execution.child.status }, + expected: expectedCode, + name, + ok: execution.child.status !== 0 && execution.output.ok === false && actualCodes.includes(expectedCode), + }); +} + +try { + const groupPattern = new RegExp(Object.keys(schema.$defs.group.patternProperties)[0]); + const rootPattern = new RegExp(Object.keys(schema.patternProperties)[0]); + const unsafeSegments = ["__proto__", "prototype", "constructor"]; + results.push({ + actual: { + groupDescription: schema.$defs.group.properties.$description.type, + tokenDescription: schema.$defs.token.properties.$description.type, + unsafeGroupMatches: unsafeSegments.filter((segment) => groupPattern.test(segment)), + unsafeRootMatches: unsafeSegments.filter((segment) => rootPattern.test(segment)), + }, + expected: "string descriptions and no unsafe path segments", + name: "schema_runtime_boundary_parity", + ok: schema.$defs.group.properties.$description.type === "string" + && schema.$defs.token.properties.$description.type === "string" + && unsafeSegments.every((segment) => !groupPattern.test(segment) && !rootPattern.test(segment)), + }); + const output = path.join(tempRoot, "tokens.css"); + const manifest = path.join(tempRoot, "manifest.json"); + const validBuild = run(builder, ["--source", "consumer-reference/fixtures/token-portability/valid-reference.json", "--output", output, "--manifest", manifest, "--adapter", "style-dictionary", "--fail-on-warning"]); + recordAccepted("valid_reference_build", validBuild, () => { + const css = fs.readFileSync(output, "utf8"); + return css.includes("var(--color-accent)") && css.includes("var(--space-small)") && !css.includes("[object Object]"); + }); + recordAccepted("valid_reference_manifest", run(validator, ["--manifest", manifest])); + + for (const fixture of ["valid-whole-alias.json", "valid-border.json"]) { + recordAccepted(fixture.replace(".json", ""), run(builder, ["--source", `consumer-reference/fixtures/token-portability/${fixture}`, "--output", output, "--manifest", manifest, "--fail-on-warning"])); + } + const invalidFixtures = [ + ["invalid-extends.json", "token_extends_forbidden"], + ["invalid-json-pointer.json", "token_json_pointer_forbidden"], + ["invalid-resolver.json", "token_resolver_forbidden"], + ["invalid-unknown-reserved.json", "token_unknown_reserved"], + ["invalid-untested-type.json", "token_type_unsupported"], + ["invalid-dangling-alias.json", "alias_dangling"], + ["invalid-cycle.json", "alias_cycle"], + ["invalid-type-mismatch.json", "alias_type_mismatch"], + ]; + for (const [fixture, expected] of invalidFixtures) { + recordRejected(fixture.replace(".json", ""), run(builder, ["--source", `consumer-reference/fixtures/token-portability/${fixture}`, "--output", output, "--manifest", manifest, "--fail-on-warning"]), expected); + } + + const invalidUnit = path.join(tempRoot, "invalid-unit.json"); + fs.writeFileSync(invalidUnit, '{"space":{"$type":"dimension","bad":{"$value":{"value":1,"unit":"vh"}}}}\n'); + recordRejected("invalid_untested_unit", run(builder, ["--source", invalidUnit, "--output", output, "--manifest", manifest, "--fail-on-warning"]), "token_unit_unsupported"); + const partialAlias = path.join(tempRoot, "invalid-partial-alias.json"); + fs.writeFileSync(partialAlias, '{"space":{"$type":"dimension","bad":{"$value":"calc({space.base} * 2)"}}}\n'); + recordRejected("invalid_partial_alias", run(builder, ["--source", partialAlias, "--output", output, "--manifest", manifest, "--fail-on-warning"]), "alias_whole_required"); + const invalidGroupDescription = path.join(tempRoot, "invalid-group-description.json"); + fs.writeFileSync(invalidGroupDescription, '{"space":{"$description":{"unsafe":true},"$type":"dimension","small":{"$value":{"value":1,"unit":"px"}}}}\n'); + recordRejected("invalid_group_description", run(builder, ["--source", invalidGroupDescription, "--output", output, "--manifest", manifest, "--fail-on-warning"]), "token_description_invalid"); + const invalidTokenDescription = path.join(tempRoot, "invalid-token-description.json"); + fs.writeFileSync(invalidTokenDescription, '{"space":{"$type":"dimension","small":{"$description":42,"$value":{"value":1,"unit":"px"}}}}\n'); + recordRejected("invalid_token_description", run(builder, ["--source", invalidTokenDescription, "--output", output, "--manifest", manifest, "--fail-on-warning"]), "token_description_invalid"); + const duplicateSourceProperty = path.join(tempRoot, "invalid-duplicate-source-property.json"); + fs.writeFileSync(duplicateSourceProperty, '{"space":{"$type":"dimension","$type":"dimension","small":{"$value":{"value":1,"unit":"px"}}}}\n'); + recordRejected("duplicate_source_property", run(builder, ["--source", duplicateSourceProperty, "--output", output, "--manifest", manifest, "--fail-on-warning"]), "token_json_invalid"); + for (const [name, sourceText] of [ + ["constructor", '{"constructor":{"$type":"dimension","small":{"$value":{"value":1,"unit":"px"}}}}\n'], + ["prototype", '{"prototype":{"$type":"dimension","small":{"$value":{"value":1,"unit":"px"}}}}\n'], + ["__proto__", '{"__proto__":{"$type":"dimension","small":{"$value":{"value":1,"unit":"px"}}}}\n'], + ]) { + const unsafePath = path.join(tempRoot, `invalid-path-${name}.json`); + fs.writeFileSync(unsafePath, sourceText); + recordRejected(`invalid_path_${name}`, run(builder, ["--source", unsafePath, "--output", output, "--manifest", manifest, "--fail-on-warning"]), "token_path_segment_unsafe"); + } + + const baselineSource = fs.readFileSync(path.join(repositoryRoot, "consumer-reference/fixtures/token-portability/valid-reference.json"), "utf8"); + fs.writeFileSync(path.join(tempRoot, "source.json"), baselineSource); + const baselineBuild = run(builder, ["--source", path.join(tempRoot, "source.json"), "--output", output, "--manifest", manifest, "--fail-on-warning"]); + recordAccepted("artifact_baseline", baselineBuild); + const baselineCss = fs.readFileSync(output, "utf8"); + const baselineManifest = fs.readFileSync(manifest, "utf8"); + fs.writeFileSync(manifest, baselineManifest.replace('"schemaVersion": "1.0",', '"schemaVersion": "1.0",\n "schemaVersion": "1.0",')); + recordRejected("duplicate_manifest_property", run(validator, ["--manifest", manifest]), "artifact_manifest_invalid"); + const duplicateValidatorSource = baselineSource.replace('"$type": "color",', '"$type": "color",\n "$type": "color",'); + fs.writeFileSync(path.join(tempRoot, "source.json"), duplicateValidatorSource); + const duplicateSourceManifest = JSON.parse(baselineManifest); + duplicateSourceManifest.inputHash = `sha256:${crypto.createHash("sha256").update(duplicateValidatorSource).digest("hex")}`; + fs.writeFileSync(manifest, `${JSON.stringify(duplicateSourceManifest, null, 2)}\n`); + recordRejected("duplicate_validator_source_property", run(validator, ["--manifest", manifest]), "artifact_source_invalid"); + fs.writeFileSync(path.join(tempRoot, "source.json"), baselineSource); + fs.writeFileSync(manifest, baselineManifest); + const symlinkSource = path.join(tempRoot, "symlink-source.json"); + fs.symlinkSync(path.join(tempRoot, "source.json"), symlinkSource); + recordRejected("symlink_source", run(builder, ["--source", symlinkSource, "--output", output, "--manifest", manifest, "--fail-on-warning"]), "token_source_untrusted"); + const realOutput = path.join(tempRoot, "real-tokens.css"); + fs.renameSync(output, realOutput); + fs.symlinkSync(realOutput, output); + recordRejected("symlink_validator_output", run(validator, ["--manifest", manifest]), "artifact_output_untrusted"); + recordRejected("symlink_builder_output", run(builder, ["--source", path.join(tempRoot, "source.json"), "--output", output, "--manifest", manifest, "--fail-on-warning"]), "artifact_output_untrusted"); + fs.rmSync(output); + fs.renameSync(realOutput, output); + const realManifest = path.join(tempRoot, "real-manifest.json"); + fs.renameSync(manifest, realManifest); + fs.symlinkSync(realManifest, manifest); + recordRejected("symlink_validator_manifest", run(validator, ["--manifest", manifest]), "artifact_manifest_untrusted"); + recordRejected("symlink_builder_manifest", run(builder, ["--source", path.join(tempRoot, "source.json"), "--output", output, "--manifest", manifest, "--fail-on-warning"]), "artifact_manifest_untrusted"); + fs.rmSync(manifest); + fs.renameSync(realManifest, manifest); + const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-reference-escape-")); + const escapedSource = path.join(outsideRoot, "source.json"); + fs.writeFileSync(escapedSource, baselineSource); + recordRejected("escaped_source", run(builder, ["--source", escapedSource, "--output", output, "--manifest", manifest, "--fail-on-warning"]), "token_source_untrusted"); + const escapedOutput = path.join(outsideRoot, "tokens.css"); + recordRejected("escaped_output", run(builder, ["--source", path.join(tempRoot, "source.json"), "--output", escapedOutput, "--manifest", manifest, "--fail-on-warning"]), "artifact_output_untrusted"); + const escapedManifest = path.join(outsideRoot, "manifest.json"); + recordRejected("escaped_manifest", run(builder, ["--source", path.join(tempRoot, "source.json"), "--output", output, "--manifest", escapedManifest, "--fail-on-warning"]), "artifact_manifest_untrusted"); + fs.rmSync(outsideRoot, { force: true, recursive: true }); + const artifactCases = [ + ["no_output", "artifact_output_missing", () => fs.rmSync(output)], + ["zero_count", "artifact_zero_count", (item) => { item.sourceCount = 0; item.outputCount = 0; item.declarations = []; }], + ["warning", "artifact_warning", (item) => { item.warnings = ["synthetic warning"]; }], + ["missing_declaration", "artifact_declaration_missing", (item) => { item.declarations = item.declarations.slice(1); }], + ["duplicate_declaration", "artifact_declaration_duplicate", (item) => { item.declarations.push(item.declarations[0]); }], + ["object_sentinel", "artifact_object_sentinel", () => fs.writeFileSync(output, baselineCss.replace("120ms", "[object Object]"))], + ["unresolved_alias", "artifact_unresolved_value", () => fs.writeFileSync(output, baselineCss.replace("120ms", "{duration.quick}"))], + ["count_mismatch", "artifact_count_mismatch", (item) => { item.outputCount += 1; }], + ["scaffold_manifest", "artifact_scaffold_forbidden", (item) => { item.scaffold = true; }], + ["forged_value", "artifact_source_output_mismatch", (item) => { + const changed = baselineCss.replace("120ms", "999999s !important"); + fs.writeFileSync(output, changed); + item.outputHash = `sha256:${crypto.createHash("sha256").update(changed).digest("hex")}`; + item.declarations.find((declaration) => declaration.name === "--duration-quick").value = "999999s !important"; + }], + ]; + for (const [name, expected, mutate] of artifactCases) { + fs.writeFileSync(output, baselineCss); + const item = JSON.parse(baselineManifest); + mutate(item); + fs.writeFileSync(manifest, `${JSON.stringify(item, null, 2)}\n`); + recordRejected(name, run(validator, ["--manifest", manifest]), expected); + } +} finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); +} + +const failures = results.filter((result) => !result.ok).map((result) => `missing_semantic:${result.name}:${result.expected}`); +const report = { failures, ok: failures.length === 0, results }; +console.log(JSON.stringify(report, null, 2)); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/test-renderer-purity.mjs b/scripts/test-renderer-purity.mjs new file mode 100644 index 0000000..9a756e7 --- /dev/null +++ b/scripts/test-renderer-purity.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const validator = path.join(repositoryRoot, "scripts", "validate-renderer-purity.mjs"); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-pr4-renderer-")); +const cases = []; + +function run(renderer) { + const child = spawnSync(process.execPath, [validator, "--renderer", renderer, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + return { child, output: JSON.parse(child.stdout) }; +} + +try { + const valid = run("tests/helpers/render-consumer-reference.mjs"); + cases.push({ actual: { codes: valid.output.failures.map((failure) => failure.code), status: valid.child.status }, expected: "pure import and exit:0", name: "canonical_renderer_import", ok: valid.child.status === 0 && valid.output.ok === true }); + const mutation = path.join(tempRoot, "render-consumer-reference.mjs"); + fs.writeFileSync(mutation, 'import fs from "node:fs";\nimport { patterns } from "../../scripts/pattern-data.mjs";\nfs.appendFileSync("CATALOG.md", "mutation");\nexport { patterns };\n'); + const invalid = run(mutation); + const codes = invalid.output.failures.map((failure) => failure.code); + cases.push({ actual: { codes, status: invalid.child.status }, expected: "renderer_side_effect_capability", name: "renderer_side_effect", ok: invalid.child.status !== 0 && codes.includes("renderer_side_effect_capability") }); +} finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); +} + +const failures = cases.filter((testCase) => !testCase.ok).map((testCase) => `missing_semantic:${testCase.name}:${testCase.expected}`); +const result = { failures, ok: failures.length === 0, results: cases }; +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/scripts/test-summarize-sentinel-calibration.mjs b/scripts/test-summarize-sentinel-calibration.mjs new file mode 100644 index 0000000..4046ea8 --- /dev/null +++ b/scripts/test-summarize-sentinel-calibration.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { BASELINE_ENVIRONMENT, BASELINE_REFERENCE, sha256 } from "./baseline-contract.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const summarizer = path.join(repositoryRoot, "scripts", "summarize-sentinel-calibration.mjs"); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-pr4-summary-")); +const png = fs.readFileSync(path.join(repositoryRoot, BASELINE_REFERENCE.baseline.path)); +const dom = Buffer.from("
\n"); +const ax = Buffer.from("- region \"Project cards\"\n"); +const pngHash = sha256(png); +const metadataHash = sha256(JSON.stringify({ environment: BASELINE_ENVIRONMENT, reference: BASELINE_REFERENCE })); + +function playwrightReport() { + return { + config: { projects: [{ id: "chromium", name: "chromium" }], version: "1.61.0", workers: 1 }, + errors: [], + stats: { expected: 1, flaky: 0, skipped: 0, unexpected: 0 }, + suites: [{ + file: "consumer-reference-sentinels.spec.mjs", + specs: [{ + ok: true, + tests: [{ + expectedStatus: "passed", + projectId: "chromium", + projectName: "chromium", + results: [{ errors: [], status: "passed" }], + status: "expected", + }], + title: "canonical consumer reference preserves computed layout before its locator screenshot", + }], + }], + }; +} + +function writeJson(file, value) { + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function writeRun(root, run) { + const runRoot = path.join(root, `run-${String(run).padStart(2, "0")}`); + fs.mkdirSync(runRoot, { recursive: true }); + fs.writeFileSync(path.join(runRoot, "actual.png"), png); + fs.writeFileSync(path.join(runRoot, "dom.html"), dom); + fs.writeFileSync(path.join(runRoot, "ax.txt"), ax); + writeJson(path.join(runRoot, "playwright.json"), playwrightReport()); + writeJson(path.join(runRoot, "exit.json"), { exit_code: 0, run, schema_version: "1.0" }); + writeJson(path.join(runRoot, "comparison.json"), { + actual_sha256: pngHash, + assertion: "visual_geometry_matches_proposed_baseline", + diff_pixels: 0, + expected_sha256: BASELINE_REFERENCE.baseline.sha256, + max_diff_pixels: 0, + run, + schema_version: "1.0", + status: "passed", + threshold: 0, + }); + writeJson(path.join(runRoot, "metadata.json"), { + architecture: "amd64", + ax_sha256: sha256(ax), + dom_sha256: sha256(dom), + environment: BASELINE_ENVIRONMENT, + metadata_sha256: metadataHash, + png_sha256: pngHash, + reference: BASELINE_REFERENCE, + run, + schema_version: "1.0", + }); + return runRoot; +} + +function invoke(name, mutate, identity = {}) { + const input = path.join(tempRoot, name, "raw"); + const outputFile = path.join(input, "calibration.json"); + for (let run = 1; run <= 20; run += 1) writeRun(input, run); + mutate?.(path.join(input, "run-01")); + const sha = identity.sha ?? "0".repeat(40); + const checkoutSha = identity.checkoutSha ?? sha; + const executionRepository = identity.executionRepository ?? "changeroa/StyleGallery"; + const headSha = identity.headSha ?? "1".repeat(40); + const repository = identity.repository ?? "changeroa/StyleGallery"; + const runId = identity.runId ?? "123"; + const artifactName = identity.artifactName ?? `chromium-sentinel-calibration-${runId}-${sha}`; + const child = spawnSync(process.execPath, [ + summarizer, + "--input", input, + "--output", outputFile, + "--repository", repository, + "--execution-repository", executionRepository, + "--workflow", ".github/workflows/validate.yml", + "--run-id", runId, + "--run-attempt", "1", + "--sha", sha, + "--checkout-sha", checkoutSha, + "--head-sha", headSha, + "--artifact-name", artifactName, + "--json", + ], { cwd: repositoryRoot, encoding: "utf8" }); + return { + output: fs.existsSync(outputFile) && fs.lstatSync(outputFile).isFile() ? JSON.parse(fs.readFileSync(outputFile, "utf8")) : null, + report: JSON.parse(child.stdout), + status: child.status, + }; +} + +const results = []; +try { + const valid = invoke("valid"); + results.push({ + actual: { outputRuns: valid.output?.runs.length, report: valid.report, status: valid.status }, + expected: "20 truth-derived stable runs awaiting external verification and zero diff statistics", + name: "valid_raw_calibration", + ok: valid.status === 0 && valid.report.ok === true && valid.report.status === "awaiting_external_verification" + && valid.output?.status === "awaiting_external_verification" + && valid.output?.committed_ci.external_verification === null + && valid.output?.runs.length === 20 && new Set(valid.output.runs.map((run) => run.png_sha256)).size === 1, + }); + const pullRequestMergeSha = "11f4668fe5988720c27e88ec7203ecd1685a40df"; + const pullRequestHeadSha = "8b8eaed41094286138973157b581a1d9ab9957a8"; + const validFork = invoke("valid_explicit_fork", undefined, { + executionRepository: "ark-jo/StyleGallery", + headSha: pullRequestHeadSha, + runId: "29258810962", + sha: pullRequestMergeSha, + }); + results.push({ + actual: { committedCi: validFork.output?.committed_ci, report: validFork.report, status: validFork.status }, + expected: "canonical upstream plus explicit execution fork with distinct merge/head SHAs", + name: "valid_explicit_fork_pull_request_identity", + ok: validFork.status === 0 && validFork.report.ok === true + && validFork.report.status === "awaiting_external_verification" + && validFork.output?.status === "awaiting_external_verification" + && validFork.output?.committed_ci.external_verification === null + && validFork.output?.committed_ci.repository === "changeroa/StyleGallery" + && validFork.output?.committed_ci.execution_repository === "ark-jo/StyleGallery" + && validFork.output?.committed_ci.sha === pullRequestMergeSha + && validFork.output?.committed_ci.checkout_sha === pullRequestMergeSha + && validFork.output?.committed_ci.head_sha === pullRequestHeadSha, + }); + const invalidCases = [ + ["missing_exit", (root) => fs.rmSync(path.join(root, "exit.json")), "calibration_raw_file_missing"], + ["nonzero_exit", (root) => writeJson(path.join(root, "exit.json"), { exit_code: 1, run: 1, schema_version: "1.0" }), "calibration_exit_unsuccessful"], + ["fake_playwright", (root) => writeJson(path.join(root, "playwright.json"), {}), "calibration_playwright_identity"], + ["duplicate_metadata", (root) => fs.copyFileSync(path.join(root, "metadata.json"), path.join(root, "metadata-copy.json")), "calibration_raw_file_unknown"], + ["unknown_metadata", (root) => { + const file = path.join(root, "metadata.json"); + const metadata = JSON.parse(fs.readFileSync(file, "utf8")); + writeJson(file, { ...metadata, completed: true }); + }, "calibration_metadata_property_unknown"], + ["duplicate_metadata_property", (root) => { + const file = path.join(root, "metadata.json"); + const metadata = fs.readFileSync(file, "utf8").replace('"schema_version": "1.0"', '"schema_version": "1.0",\n "schema_version": "1.0"'); + fs.writeFileSync(file, metadata); + }, "calibration_metadata_missing_invalid"], + ["missing_comparison", (root) => fs.rmSync(path.join(root, "comparison.json")), "calibration_raw_file_missing"], + ["relaxed_comparison_threshold", (root) => { + const file = path.join(root, "comparison.json"); + const comparison = JSON.parse(fs.readFileSync(file, "utf8")); + writeJson(file, { ...comparison, threshold: 0.1 }); + }, "calibration_comparison_mismatch"], + ["invalid_run_directory", (root) => { + fs.rmSync(root, { recursive: true }); + fs.writeFileSync(root, "not a directory\n"); + }, "calibration_run_directory_invalid"], + ["invalid_input_file", (root) => { + const input = path.dirname(root); + fs.rmSync(input, { recursive: true }); + fs.writeFileSync(input, "not a directory\n"); + }, "calibration_input_invalid"], + ["symlinked_input_directory", (root) => { + const input = path.dirname(root); + const external = path.join(tempRoot, "external-input"); + fs.cpSync(input, external, { recursive: true }); + fs.rmSync(input, { recursive: true }); + fs.symlinkSync(external, input); + }, "calibration_input_invalid"], + ["invalid_output_directory", (root) => fs.mkdirSync(path.join(path.dirname(root), "calibration.json")), "calibration_output_invalid"], + ["unknown_root_file", (root) => fs.writeFileSync(path.join(path.dirname(root), "forged.json"), "{}\n"), "calibration_run_directory_unknown"], + ["symlinked_run_directory", (root) => { + const external = path.join(tempRoot, "external-run"); + fs.cpSync(path.join(path.dirname(root), "run-02"), external, { recursive: true }); + fs.rmSync(root, { recursive: true }); + fs.symlinkSync(external, root); + }, "calibration_run_directory_invalid"], + ["artifact_directory", (root) => { + fs.rmSync(path.join(root, "actual.png")); + fs.mkdirSync(path.join(root, "actual.png")); + }, "calibration_artifact_hash_mismatch"], + ["oversized_metadata", (root) => fs.appendFileSync(path.join(root, "metadata.json"), " ".repeat(1024 * 1024)), "calibration_raw_file_oversized"], + ["oversized_png", (root) => fs.appendFileSync(path.join(root, "actual.png"), Buffer.alloc(5 * 1024 * 1024)), "calibration_raw_file_oversized"], + ]; + for (const [name, mutate, expected] of invalidCases) { + const actual = invoke(name, mutate); + const codes = actual.report.failures.map((failure) => failure.code); + results.push({ actual: { codes, outputExists: actual.output !== null, status: actual.status }, expected, name, ok: actual.status !== 0 && actual.output === null && actual.report.status === "incomplete" && codes.includes(expected) }); + } + const identityCases = [ + ["checkout_sha_not_tested_sha", { checkoutSha: "1".repeat(40) }], + ["artifact_bound_to_head_sha", { artifactName: `chromium-sentinel-calibration-123-${"1".repeat(40)}` }], + ["unrecognized_execution_repository", { executionRepository: "untrusted/StyleGallery" }], + ["swapped_canonical_and_execution_repository", { executionRepository: "changeroa/StyleGallery", repository: "ark-jo/StyleGallery" }], + ]; + for (const [name, identity] of identityCases) { + const actual = invoke(name, undefined, identity); + const codes = actual.report.failures.map((failure) => failure.code); + results.push({ + actual: { codes, outputExists: actual.output !== null, status: actual.status }, + expected: "calibration_committed_ci_invalid", + name, + ok: actual.status !== 0 && actual.output === null && codes.includes("calibration_committed_ci_invalid"), + }); + } +} finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); +} + +const failures = results.filter((result) => !result.ok).map((result) => `missing_semantic:${result.name}:${result.expected}`); +const report = { failures, ok: failures.length === 0, results }; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +if (!report.ok) process.exitCode = 1; diff --git a/scripts/test-validate-baseline-manifest.mjs b/scripts/test-validate-baseline-manifest.mjs new file mode 100644 index 0000000..082a7f5 --- /dev/null +++ b/scripts/test-validate-baseline-manifest.mjs @@ -0,0 +1,234 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { BASELINE_METADATA_SHA256, BASELINE_REFERENCE, sha256 } from "./baseline-contract.mjs"; +import { baselineSchemaParity } from "./baseline-schema-parity.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const validator = path.join(repositoryRoot, "scripts", "validate-baseline-manifest.mjs"); +const canonical = JSON.parse(fs.readFileSync(path.join(repositoryRoot, "consumer-reference", "baselines", "calibration.json"), "utf8")); +const canonicalManifest = JSON.parse(fs.readFileSync(path.join(repositoryRoot, "consumer-reference", "baselines", "manifest.json"), "utf8")); +const schema = JSON.parse(fs.readFileSync(path.join(repositoryRoot, "consumer-reference", "schema", "calibration-record.schema.json"), "utf8")); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-pr4-calibration-")); +const completedEvidence = { + artifactId: "8283099324", + artifactName: "chromium-sentinel-calibration-29260372260-18229be570766d3b42f5600955120bfcba690b76", + artifactApiDigest: "sha256:3f11a517b447e1b5a1da17d9ee66ba2dd947fc8a0a482c447556ef00652e6074", + artifactExpiresAt: "2026-07-27T15:01:36Z", + artifactSize: 70310, + checkoutSha: "18229be570766d3b42f5600955120bfcba690b76", + headSha: "3bfdd25ec9a2df4ca84e19541365d48842a73f59", + rawEvidenceSha256: "1f125d5b321063b364e19283897c78c126d73eb1b3368d14686c494b1296dfab", + runId: "29260372260", + source: "https://github.com/ark-jo/StyleGallery/actions/runs/29260372260", + verifiedAt: "2026-07-13T15:04:41Z", +}; + +function externalVerification() { + return { + artifact: { + api_digest: completedEvidence.artifactApiDigest, + expires_at: completedEvidence.artifactExpiresAt, + id: completedEvidence.artifactId, + name: completedEvidence.artifactName, + size_in_bytes: completedEvidence.artifactSize, + }, + repository_relationship: { + execution_is_fork: true, + parent: "changeroa/StyleGallery", + source: "changeroa/StyleGallery", + }, + source: completedEvidence.source, + verified_at: completedEvidence.verifiedAt, + }; +} + +function completeRecord(committedCi = {}) { + const runs = Array.from({ length: 20 }, (_, index) => ({ + architecture: "amd64", + ax_sha256: "a".repeat(64), + completed: true, + dom_sha256: "b".repeat(64), + metadata_sha256: BASELINE_METADATA_SHA256, + png_sha256: BASELINE_REFERENCE.baseline.sha256, + run: index + 1, + screenshot_diff_pixels: 0, + })); + return { + ...canonical, + committed_ci: { + artifact_name: completedEvidence.artifactName, + checkout_sha: completedEvidence.checkoutSha, + execution_repository: "ark-jo/StyleGallery", + external_verification: externalVerification(), + head_sha: completedEvidence.headSha, + raw_evidence_sha256: completedEvidence.rawEvidenceSha256, + repository: "changeroa/StyleGallery", + run_attempt: "1", + run_id: completedEvidence.runId, + sha: completedEvidence.checkoutSha, + workflow: ".github/workflows/validate.yml", + ...committedCi, + }, + status: "completed", + runs, + }; +} + +const cases = [ + ["nineteen_of_twenty", (record) => ({ ...record, runs: record.runs.slice(0, 19) }), "calibration_run_count"], + ["duplicate_run", (record) => ({ ...record, runs: record.runs.map((run, index) => index === 19 ? { ...run, run: 19 } : run) }), "calibration_run_duplicate"], + ["missing_run", (record) => ({ ...record, runs: record.runs.map((run, index) => index === 19 ? { ...run, run: 21 } : run) }), "calibration_run_set"], + ["architecture_variance", (record) => ({ ...record, runs: record.runs.map((run, index) => index === 19 ? { ...run, architecture: "arm64" } : run) }), "calibration_architecture_variance"], + ["environment_mismatch", (record) => ({ ...record, environment: { ...record.environment, architecture: "arm64" } }), "calibration_environment_mismatch"], + ["hash_variance", (record) => ({ ...record, runs: record.runs.map((run, index) => index === 19 ? { ...run, png_sha256: "e".repeat(64) } : run) }), "calibration_hash_variance"], + ["metadata_variance", (record) => ({ ...record, runs: record.runs.map((run, index) => index === 19 ? { ...run, metadata_sha256: "f".repeat(64) } : run) }), "calibration_metadata_variance"], + ["incomplete_run", (record) => ({ ...record, runs: record.runs.map((run, index) => index === 19 ? { ...run, completed: false } : run) }), "calibration_run_incomplete"], + ["nonzero_diff", (record) => ({ ...record, runs: record.runs.map((run, index) => index === 19 ? { ...run, screenshot_diff_pixels: 1 } : run) }), "calibration_diff_nonzero"], + ["malformed_hash", (record) => ({ ...record, runs: record.runs.map((run, index) => index === 19 ? { ...run, png_sha256: "x" } : run) }), "calibration_hash_invalid"], + ["wrong_stable_baseline_hash", (record) => ({ ...record, runs: record.runs.map((run) => ({ ...run, png_sha256: "d".repeat(64) })) }), "calibration_baseline_hash_mismatch"], + ["wrong_stable_metadata_hash", (record) => ({ ...record, runs: record.runs.map((run) => ({ ...run, metadata_sha256: "c".repeat(64) })) }), "calibration_metadata_hash_mismatch"], + ["unknown_run_property", (record) => ({ ...record, runs: record.runs.map((run, index) => index === 19 ? { ...run, forged: true } : run) }), "calibration_run_property_unknown"], + ["checkout_sha_not_tested_sha", (record) => ({ ...record, committed_ci: { ...record.committed_ci, checkout_sha: record.committed_ci.head_sha } }), "calibration_committed_ci_invalid"], + ["artifact_bound_to_head_sha", (record) => ({ ...record, committed_ci: { ...record.committed_ci, artifact_name: `chromium-sentinel-calibration-${record.committed_ci.run_id}-${record.committed_ci.head_sha}` } }), "calibration_committed_ci_invalid"], + ["unrecognized_execution_repository", (record) => ({ ...record, committed_ci: { ...record.committed_ci, execution_repository: "untrusted/StyleGallery" } }), "calibration_committed_ci_invalid"], + ["swapped_canonical_and_execution_repository", (record) => ({ ...record, committed_ci: { ...record.committed_ci, execution_repository: "changeroa/StyleGallery", repository: "ark-jo/StyleGallery" } }), "calibration_committed_ci_invalid"], + ["missing_execution_repository", (record) => { + const { execution_repository: omitted, ...committedCi } = record.committed_ci; + return { ...record, committed_ci: committedCi }; + }, "calibration_committed_ci_invalid"], + ["completed_missing_external_verification", (record) => { + const { external_verification: omitted, ...committedCi } = record.committed_ci; + return { ...record, committed_ci: committedCi }; + }, "calibration_external_verification_missing"], + ["forged_external_artifact_id", (record) => ({ ...record, committed_ci: { ...record.committed_ci, external_verification: { ...record.committed_ci.external_verification, artifact: { ...record.committed_ci.external_verification.artifact, id: "8283099325" } } } }), "calibration_external_verification_invalid"], + ["forged_external_artifact_digest", (record) => ({ ...record, committed_ci: { ...record.committed_ci, external_verification: { ...record.committed_ci.external_verification, artifact: { ...record.committed_ci.external_verification.artifact, api_digest: `sha256:${"f".repeat(64)}` } } } }), "calibration_external_verification_invalid"], + ["raw_digest_substituted_for_artifact_digest", (record) => ({ ...record, committed_ci: { ...record.committed_ci, external_verification: { ...record.committed_ci.external_verification, artifact: { ...record.committed_ci.external_verification.artifact, api_digest: `sha256:${record.committed_ci.raw_evidence_sha256}` } } } }), "calibration_external_verification_invalid"], + ["forged_external_source", (record) => ({ ...record, committed_ci: { ...record.committed_ci, external_verification: { ...record.committed_ci.external_verification, source: "https://github.com/ark-jo/StyleGallery/actions/runs/1" } } }), "calibration_external_verification_invalid"], + ["forged_fork_parent_relationship", (record) => ({ ...record, committed_ci: { ...record.committed_ci, external_verification: { ...record.committed_ci.external_verification, repository_relationship: { ...record.committed_ci.external_verification.repository_relationship, parent: "untrusted/StyleGallery" } } } }), "calibration_external_verification_invalid"], + ["invalid_committed_ci", (record) => ({ ...record, committed_ci: { artifact_name: "forged", extra: true, run_id: "abc", sha: "x" } }), "calibration_committed_ci_property_unknown"], + ["owner_approval_preclaim", (record) => ({ ...record, baseline_owner_approval: "approved" }), "baseline_owner_approval_invalid"], + ["pending_evidence_preclaim", (record) => ({ ...record, status: "awaiting_committed_ci" }), "calibration_pending_evidence_forbidden"], +]; + +const results = []; +try { + const schemaParity = baselineSchemaParity(schema, canonical, completedEvidence); + results.push({ actual: { schemaParity }, expected: "recursive schema/runtime identity parity", name: "schema_runtime_parity", ok: schemaParity }); + const validFixture = path.join(tempRoot, "valid-completed.json"); + fs.writeFileSync(validFixture, `${JSON.stringify(completeRecord(), null, 2)}\n`); + const validChild = spawnSync(process.execPath, [validator, "--calibration", validFixture, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + const validOutput = JSON.parse(validChild.stdout); + results.push({ actual: { codes: validOutput.failures.map((failure) => failure.code), status: validChild.status }, expected: "completed calibration and exit:0", name: "valid_completed_calibration", ok: validChild.status === 0 && validOutput.ok === true }); + const awaitingExternalFixture = path.join(tempRoot, "valid-awaiting-external-verification.json"); + fs.writeFileSync(awaitingExternalFixture, `${JSON.stringify({ + ...completeRecord({ external_verification: null }), + status: "awaiting_external_verification", + }, null, 2)}\n`); + const awaitingExternalChild = spawnSync(process.execPath, [validator, "--calibration", awaitingExternalFixture, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + const awaitingExternalOutput = JSON.parse(awaitingExternalChild.stdout); + results.push({ + actual: { codes: awaitingExternalOutput.failures.map((failure) => failure.code), status: awaitingExternalChild.status }, + expected: "pre-upload calibration awaits external verification and exits 0", + name: "valid_awaiting_external_verification", + ok: awaitingExternalChild.status === 0 && awaitingExternalOutput.ok === true, + }); + const pendingFixture = path.join(tempRoot, "valid-awaiting-committed-ci.json"); + fs.writeFileSync(pendingFixture, `${JSON.stringify({ ...canonical, committed_ci: null, runs: [], status: "awaiting_committed_ci" }, null, 2)}\n`); + const pendingChild = spawnSync(process.execPath, [validator, "--calibration", pendingFixture, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + const pendingOutput = JSON.parse(pendingChild.stdout); + results.push({ + actual: { codes: pendingOutput.failures.map((failure) => failure.code), status: pendingChild.status }, + expected: "initial pending record and exit:0", + name: "valid_awaiting_committed_ci", + ok: pendingChild.status === 0 && pendingOutput.ok === true, + }); + const pullRequestFixture = path.join(tempRoot, "valid-pull-request-merge-head.json"); + const mergeSha = "11f4668fe5988720c27e88ec7203ecd1685a40df"; + const headSha = "8b8eaed41094286138973157b581a1d9ab9957a8"; + fs.writeFileSync(pullRequestFixture, `${JSON.stringify({ + ...completeRecord({ + artifact_name: `chromium-sentinel-calibration-29258810962-${mergeSha}`, + checkout_sha: mergeSha, + execution_repository: "ark-jo/StyleGallery", + external_verification: null, + head_sha: headSha, + repository: "changeroa/StyleGallery", + run_id: "29258810962", + sha: mergeSha, + }), + status: "awaiting_external_verification", + }, null, 2)}\n`); + const pullRequestChild = spawnSync(process.execPath, [validator, "--calibration", pullRequestFixture, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + const pullRequestOutput = JSON.parse(pullRequestChild.stdout); + results.push({ + actual: { codes: pullRequestOutput.failures.map((failure) => failure.code), status: pullRequestChild.status }, + expected: "distinct canonical pull-request merge checkout and head SHAs", + name: "valid_pull_request_merge_and_head_identity", + ok: pullRequestChild.status === 0 && pullRequestOutput.ok === true, + }); + for (const [name, mutate, expected] of cases) { + const fixture = path.join(tempRoot, `${name}.json`); + fs.writeFileSync(fixture, `${JSON.stringify(mutate(completeRecord()), null, 2)}\n`); + const child = spawnSync(process.execPath, [validator, "--calibration", fixture, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + const output = JSON.parse(child.stdout); + const codes = Array.isArray(output.failures) ? output.failures.map((failure) => failure.code) : []; + results.push({ actual: { codes, status: child.status }, expected, name, ok: child.status !== 0 && codes.includes(expected) }); + } + const manifestFixture = path.join(tempRoot, "unknown-manifest-nested.json"); + fs.writeFileSync(manifestFixture, `${JSON.stringify({ + ...canonicalManifest, + calibration: { ...canonicalManifest.calibration, forged: true }, + source: { ...canonicalManifest.source, forged: true }, + }, null, 2)}\n`); + const manifestChild = spawnSync(process.execPath, [validator, "--manifest", manifestFixture, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + const manifestOutput = JSON.parse(manifestChild.stdout); + const manifestCodes = manifestOutput.failures.map((failure) => failure.code); + results.push({ + actual: { codes: manifestCodes, status: manifestChild.status }, + expected: "baseline_calibration_property_unknown and baseline_source_property_unknown", + name: "unknown_manifest_nested_properties", + ok: manifestChild.status !== 0 && manifestCodes.includes("baseline_calibration_property_unknown") && manifestCodes.includes("baseline_source_property_unknown"), + }); + const alternateFile = path.join(repositoryRoot, "package.json"); + const alternateManifestFixture = path.join(tempRoot, "alternate-baseline.json"); + fs.writeFileSync(alternateManifestFixture, `${JSON.stringify({ + ...canonicalManifest, + baseline: { path: "package.json", sha256: sha256(fs.readFileSync(alternateFile)) }, + }, null, 2)}\n`); + const alternateChild = spawnSync(process.execPath, [validator, "--manifest", alternateManifestFixture, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + const alternateOutput = JSON.parse(alternateChild.stdout); + const alternateCodes = alternateOutput.failures.map((failure) => failure.code); + results.push({ + actual: { codes: alternateCodes, status: alternateChild.status }, + expected: "baseline_baseline_reference_mismatch", + name: "alternate_in_repo_baseline", + ok: alternateChild.status !== 0 && alternateCodes.includes("baseline_baseline_reference_mismatch"), + }); + const directoryManifestFixture = path.join(tempRoot, "directory-baseline.json"); + fs.writeFileSync(directoryManifestFixture, `${JSON.stringify({ ...canonicalManifest, baseline: { path: "tests", sha256: "a".repeat(64) } }, null, 2)}\n`); + const directoryChild = spawnSync(process.execPath, [validator, "--manifest", directoryManifestFixture, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + const directoryOutput = JSON.parse(directoryChild.stdout); + results.push({ actual: { codes: directoryOutput.failures.map((failure) => failure.code), status: directoryChild.status }, expected: "baseline_baseline_file_invalid", name: "directory_manifest_target", ok: directoryChild.status !== 0 && directoryOutput.failures.some((failure) => failure.code === "baseline_baseline_file_invalid") }); + for (const [name, content, expected] of [ + ["null_calibration", "null\n", "calibration_record_invalid"], + ["duplicate_calibration_property", '{"schema_version":"1.0","schema_version":"1.0"}\n', "baseline_json_invalid"], + ]) { + const fixture = path.join(tempRoot, `${name}.json`); + fs.writeFileSync(fixture, content); + const child = spawnSync(process.execPath, [validator, "--calibration", fixture, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + const output = JSON.parse(child.stdout); + results.push({ actual: { codes: output.failures.map((failure) => failure.code), status: child.status }, expected, name, ok: child.status !== 0 && output.failures.some((failure) => failure.code === expected) }); + } +} finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); +} + +const failures = results.filter((result) => !result.ok).map((result) => `missing_semantic:${result.name}:${result.expected}`); +const report = { failures, ok: failures.length === 0, results }; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +if (!report.ok) process.exitCode = 1; diff --git a/scripts/test-validate-component-state-artifacts.mjs b/scripts/test-validate-component-state-artifacts.mjs new file mode 100644 index 0000000..aa73d50 --- /dev/null +++ b/scripts/test-validate-component-state-artifacts.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { artifactMetadata } from "./artifact-metadata.mjs"; +import { sessionLink, sha256 } from "./capture-session-contract.mjs"; +import { makePng, syntheticImage } from "./component-state-artifact-fixture.mjs"; +import { resolveProfileRecords } from "./profile-record-contract.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceProfiles = path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local"); +const validator = path.join(repositoryRoot, "scripts/validate-component-state.mjs"); +const finalizer = path.join(repositoryRoot, "scripts/finalize-component-state-evidence.mjs"); +const creator = path.join(repositoryRoot, "scripts/create-component-state-session.mjs"); + +const png = makePng(32); + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function prepareCanonical() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-state-artifacts-base-")); + const profileRoot = path.join(root, "profiles"); + const artifactRoot = path.join(root, "artifacts"); + fs.cpSync(sourceProfiles, profileRoot, { recursive: true }); + for (const entry of fs.readdirSync(profileRoot, { withFileTypes: true }).filter((item) => item.isDirectory())) { + const resolved = resolveProfileRecords(path.join(profileRoot, entry.name), []); + const statesRecord = resolved.records.states[0]; + for (const scenario of statesRecord.value.scenarios) { + const metadata = artifactMetadata(syntheticImage(entry.name, scenario.id), "image/png"); + scenario.expected.visual_image = scenario.expected.visual_image.map((expectation) => ({ environment_id: expectation.environment_id, height: metadata.height, sha256: metadata.sha256, width: metadata.width })); + } + writeJson(statesRecord.path, statesRecord.value); + } + const receiptFile = path.join(artifactRoot, "capture-session.json"); + const created = spawnSync(process.execPath, [creator, "--root", profileRoot, "--output", receiptFile, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + if (created.status !== 0) throw new Error(`canonical session creation failed: ${created.stdout}${created.stderr}`); + const receiptBytes = fs.readFileSync(receiptFile); + const receipt = JSON.parse(receiptBytes); + const captureLink = sessionLink(receipt, sha256(receiptBytes)); + const capturedAt = new Date().toISOString(); + for (const entry of fs.readdirSync(profileRoot, { withFileTypes: true }).filter((item) => item.isDirectory())) { + const resolved = resolveProfileRecords(path.join(profileRoot, entry.name), []); + const fixtures = resolved.records.fixture[0].value; + const states = new Map(resolved.records.states[0].value.scenarios.map((scenario) => [scenario.id, scenario])); + for (const fixture of fixtures.scenarios) { + const scenario = states.get(fixture.id); + const prefix = path.join(artifactRoot, "runtime", `${entry.name}-${fixture.id}`); + const attributes = Object.fromEntries(Object.entries(scenario.expected.dom).filter(([key]) => !["active", "role"].includes(key))); + writeJson(`${prefix}.dom.json`, { + activation_count: scenario.expected.activation === "allowed" ? 1 : 0, + activation_key: fixture.activation_key, + active: scenario.expected.dom.active === "true", + attributes, + capture_session: captureLink, + captured_at: capturedAt, + channel: "dom", + profile_id: resolved.profile.id, + role: scenario.expected.dom.role, + scenario_id: fixture.id, + schema_version: "1.0", + semantic_mode: scenario.semantic_mode, + visual_states: scenario.expected.visual, + }); + writeJson(`${prefix}.ax.json`, { + capture_session: captureLink, + captured_at: capturedAt, + channel: "ax", + name: fixture.label, + profile_id: resolved.profile.id, + properties: Object.fromEntries(Object.entries(scenario.expected.ax).filter(([key]) => key !== "role")), + role: scenario.expected.ax.role, + scenario_id: fixture.id, + schema_version: "1.0", + semantic_mode: scenario.semantic_mode, + }); + fs.mkdirSync(path.dirname(`${prefix}.png`), { recursive: true }); + const image = syntheticImage(entry.name, fixture.id); + fs.writeFileSync(`${prefix}.png`, image); + writeJson(`${prefix}.visual.json`, { + capture_session: captureLink, + captured_at: capturedAt, + channel: "visual", + image: { path: `${entry.name}-${fixture.id}.png`, ...artifactMetadata(image, "image/png") }, + profile_id: resolved.profile.id, + scenario_id: fixture.id, + schema_version: "1.0", + semantic_mode: scenario.semantic_mode, + source_sha256: receipt.source.sha256, + }); + } + } + const manifest = path.join(artifactRoot, "runtime-manifest.json"); + const child = spawnSync(process.execPath, [finalizer, "--root", profileRoot, "--artifact-root", artifactRoot, "--output", manifest, "--write-canonical", "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + if (child.status !== 0) throw new Error(`canonical artifact finalization failed: ${child.stdout}${child.stderr}`); + const finalization = JSON.parse(child.stdout); + if (finalization.artifactCount !== 40) throw new Error(`canonical finalization must close 40 runtime files, found ${finalization.artifactCount}`); + const passCount = JSON.parse(fs.readFileSync(manifest, "utf8")).records.flatMap((record) => record.passes).length; + if (passCount !== 30) throw new Error(`canonical finalization must retain 30 channel passes, found ${passCount}`); + return { artifactRoot, manifest, profileRoot, root }; +} + +function codes(output) { + return (output.failures ?? []).map((failure) => failure.code); +} + +function findPass(manifest, scenario, channel, record = 0) { + return manifest.records[record].passes.find((pass) => pass.scenario_id === scenario && pass.channel === channel); +} + +function refresh(pass, artifactRoot, preserveDimensions = false) { + const file = path.join(artifactRoot, pass.artifact.path); + const dimensions = { height: pass.artifact.height, width: pass.artifact.width }; + pass.artifact = { path: pass.artifact.path, ...artifactMetadata(fs.readFileSync(file), pass.artifact.media_type) }; + if (preserveDimensions) Object.assign(pass.artifact, dimensions); +} + +function mutateJsonArtifact(manifest, artifactRoot, scenario, channel, mutate) { + const pass = findPass(manifest, scenario, channel); + const file = path.join(artifactRoot, pass.artifact.path); + const document = JSON.parse(fs.readFileSync(file)); + mutate(document); + writeJson(file, document); + refresh(pass, artifactRoot); + return pass; +} + +const cases = [ + { name: "canonical", valid: true }, + { expect: "evidence_channel_duplicate", name: "channel_substitution", mutate: (m) => { findPass(m, "action-focused", "ax").channel = "dom"; } }, + { expect: "evidence_png_invalid", name: "dummy_visual_bytes", mutate: (m, a) => { const p = findPass(m, "action-focused", "visual"); fs.writeFileSync(path.join(a, p.artifact.path), "not a png"); refresh(p, a, true); } }, + { expect: "evidence_visual_image_mismatch", name: "wrong_valid_png", mutate: (m, a) => { const p = findPass(m, "action-focused", "visual"); fs.writeFileSync(path.join(a, p.artifact.path), makePng(224)); refresh(p, a); } }, + { expect: "evidence_visual_expectation_mismatch", name: "coordinated_visual_substitution", mutate: (m, a) => { const p = findPass(m, "action-focused", "visual"); const file = path.join(a, p.artifact.path); const image = makePng(224); fs.writeFileSync(file, image); refresh(p, a); const sidecar = path.join(a, p.artifact.path.replace(/\.png$/, ".visual.json")); const document = JSON.parse(fs.readFileSync(sidecar)); document.image = { path: path.basename(file), ...artifactMetadata(image, "image/png") }; writeJson(sidecar, document); } }, + { expect: "evidence_json_invalid", name: "png_as_dom", mutate: (m, a) => { const p = findPass(m, "action-focused", "dom"); fs.writeFileSync(path.join(a, p.artifact.path), png); refresh(p, a); } }, + { expect: "evidence_artifact_symlink", name: "artifact_symlink", mutate: (m, a) => { const p = findPass(m, "action-focused", "visual"); const f = path.join(a, p.artifact.path); fs.rmSync(f); fs.symlinkSync("/etc/hosts", f); } }, + { expect: "evidence_artifact_reused", name: "artifact_path_reuse", mutate: (m) => { const p = findPass(m, "action-loading-busy", "visual"); p.artifact = structuredClone(findPass(m, "action-focused", "visual").artifact); } }, + { expect: "evidence_artifact_content_reused", name: "artifact_content_reuse", mutate: (m, a) => { const source = findPass(m, "action-focused", "visual"); const target = findPass(m, "action-loading-busy", "visual"); fs.copyFileSync(path.join(a, source.artifact.path), path.join(a, target.artifact.path)); refresh(target, a); } }, + { expect: "evidence_scenario_unknown", name: "unknown_scenario", mutate: (m) => { findPass(m, "action-focused", "dom").scenario_id = "unknown-scenario"; } }, + { expect: "runtime_manifest_profile_unknown", name: "unknown_profile", mutate: (m) => { m.records[0].profile_id = "unknown-profile"; } }, + { expect: "evidence_artifact_integrity", name: "forged_hash", mutate: (m) => { findPass(m, "action-focused", "visual").artifact.sha256 = `sha256:${"0".repeat(64)}`; } }, + { expect: "evidence_artifact_missing", name: "missing_artifact", mutate: (m) => { findPass(m, "action-focused", "visual").artifact.path = "runtime/missing.png"; } }, + { expect: "evidence_artifact_type_invalid", name: "directory_artifact", mutate: (m, a) => { const p = findPass(m, "action-focused", "visual"); p.artifact.path = "runtime/directory.png"; fs.mkdirSync(path.join(a, p.artifact.path)); } }, + { expect: "evidence_artifact_escape", name: "outside_artifact", mutate: (m) => { findPass(m, "action-focused", "visual").artifact.path = "../outside.png"; } }, + { expect: "evidence_dom_content_mismatch", name: "pressed_dom_false", mutate: (m, a) => { const p = findPass(m, "toggle-focused-pressed", "dom"); const f = path.join(a, p.artifact.path); const d = JSON.parse(fs.readFileSync(f)); d.attributes["aria-pressed"] = "false"; writeJson(f, d); refresh(p, a); } }, + { expect: "evidence_dom_content_mismatch", name: "disabled_dom_false", mutate: (m, a) => { const p = findPass(m, "action-disabled-busy", "dom"); const f = path.join(a, p.artifact.path); const d = JSON.parse(fs.readFileSync(f)); d.attributes.disabled = "false"; writeJson(f, d); refresh(p, a); } }, + { expect: "evidence_runtime_identity_mismatch", name: "activation_key_mismatch", mutate: (m, a) => { const p = findPass(m, "action-focused", "dom"); const f = path.join(a, p.artifact.path); const d = JSON.parse(fs.readFileSync(f)); d.activation_key = "Space"; writeJson(f, d); refresh(p, a); } }, + { expect: "evidence_recorded_at_mismatch", name: "stale_runtime", mutate: (m) => { m.recorded_at = "2000-01-01T00:00:00.000Z"; for (const record of m.records) for (const pass of record.passes) pass.recorded_at = m.recorded_at; } }, + { expect: "evidence_dom_schema_invalid", name: "unexpected_certification", mutate: (m, a) => { const p = findPass(m, "action-focused", "dom"); const f = path.join(a, p.artifact.path); const d = JSON.parse(fs.readFileSync(f)); d.certification = true; writeJson(f, d); refresh(p, a); } }, + { expect: "evidence_dom_schema_invalid", name: "dom_extra_nested_field", mutate: (m, a) => { mutateJsonArtifact(m, a, "action-focused", "dom", (d) => { d.attributes.certification = "true"; }); } }, + { expect: "evidence_dom_schema_invalid", name: "dom_null_type", mutate: (m, a) => { mutateJsonArtifact(m, a, "action-focused", "dom", (d) => { d.active = null; }); } }, + { expect: "evidence_dom_schema_invalid", name: "dom_missing_key", mutate: (m, a) => { mutateJsonArtifact(m, a, "action-focused", "dom", (d) => { delete d.activation_count; }); } }, + { expect: "evidence_ax_schema_invalid", name: "ax_extra_nested_field", mutate: (m, a) => { mutateJsonArtifact(m, a, "action-focused", "ax", (d) => { d.properties.certification = true; }); } }, + { expect: "evidence_ax_schema_invalid", name: "ax_null_type", mutate: (m, a) => { mutateJsonArtifact(m, a, "action-focused", "ax", (d) => { d.properties.focused = null; }); } }, + { expect: "evidence_ax_schema_invalid", name: "ax_missing_key", mutate: (m, a) => { mutateJsonArtifact(m, a, "action-focused", "ax", (d) => { delete d.role; }); } }, + { expect: "capture_session_receipt_mismatch", name: "receipt_tamper", mutate: (m, a) => { const f = path.join(a, "capture-session.json"); const r = JSON.parse(fs.readFileSync(f)); r.nonce = "0".repeat(64); writeJson(f, r); } }, + { expect: "capture_session_schema_invalid", name: "receipt_source_missing", mutate: (_m, a) => { const f = path.join(a, "capture-session.json"); const r = JSON.parse(fs.readFileSync(f)); delete r.source; writeJson(f, r); } }, + { expect: "runtime_manifest_schema_invalid", name: "completed_session_source_missing", mutate: (m) => { delete m.session.source; } }, + { expect: "evidence_visual_schema_invalid", name: "visual_session_source_missing", mutate: (m, a) => { const p = findPass(m, "action-focused", "visual"); const f = path.join(a, p.artifact.path.replace(/\.png$/, ".visual.json")); const d = JSON.parse(fs.readFileSync(f)); delete d.capture_session.source; writeJson(f, d); } }, + { expect: "evidence_dom_schema_invalid", name: "dom_session_source_missing", mutate: (m, a) => { mutateJsonArtifact(m, a, "action-focused", "dom", (d) => { delete d.capture_session.source; }); } }, + { expect: "evidence_ax_schema_invalid", name: "ax_session_source_missing", mutate: (m, a) => { mutateJsonArtifact(m, a, "action-focused", "ax", (d) => { delete d.capture_session.source; }); } }, + { expect: "capture_session_receipt_mismatch", name: "isolated_session_replay", mutate: (_m, a) => { fs.copyFileSync(path.join(alternate.artifactRoot, "capture-session.json"), path.join(a, "capture-session.json")); } }, + { expect: "capture_session_missing", name: "receipt_missing", mutate: (m, a) => { fs.rmSync(path.join(a, "capture-session.json")); } }, + { expect: "capture_session_mismatch", name: "cross_session_artifact_mix", mutate: (m, a) => { const target = findPass(m, "action-focused", "dom"); const sourceManifest = JSON.parse(fs.readFileSync(alternate.manifest)); const source = findPass(sourceManifest, "action-focused", "dom"); fs.copyFileSync(path.join(alternate.artifactRoot, source.artifact.path), path.join(a, target.artifact.path)); refresh(target, a); } }, + { expect: "capture_session_mismatch", name: "visual_cross_session_replay", mutate: (m, a) => { const target = findPass(m, "action-focused", "visual"); const sourceManifest = JSON.parse(fs.readFileSync(alternate.manifest)); const source = findPass(sourceManifest, "action-focused", "visual"); fs.copyFileSync(path.join(alternate.artifactRoot, source.artifact.path), path.join(a, target.artifact.path)); fs.copyFileSync(path.join(alternate.artifactRoot, source.artifact.path.replace(/\.png$/, ".visual.json")), path.join(a, target.artifact.path.replace(/\.png$/, ".visual.json"))); refresh(target, a); } }, + { expect: "capture_session_mismatch", name: "artifact_revision_drift", mutate: (m, a) => { mutateJsonArtifact(m, a, "action-focused", "dom", (d) => { d.capture_session.revision = "0".repeat(40); }); } }, + { expect: "capture_session_mismatch", name: "artifact_environment_drift", mutate: (m, a) => { mutateJsonArtifact(m, a, "action-focused", "dom", (d) => { d.capture_session.environment.browser = "Different browser"; }); } }, + { expect: "capture_session_mismatch", name: "artifact_attempt_drift", mutate: (m, a) => { mutateJsonArtifact(m, a, "action-focused", "dom", (d) => { d.capture_session.attempt += 1; }); } }, + { expect: "capture_session_time_outside", name: "artifact_time_outside_session", mutate: (m, a) => { for (const channel of ["dom", "ax"]) { const p = mutateJsonArtifact(m, a, "action-focused", channel, (d) => { d.captured_at = "2000-01-01T00:00:00.000Z"; }); p.recorded_at = "2000-01-01T00:00:00.000Z"; } findPass(m, "action-focused", "visual").recorded_at = "2000-01-01T00:00:00.000Z"; } }, + { expect: "capture_session_time_outside", name: "completed_before_started", mutate: (m) => { m.session.completed_at = "2000-01-01T00:00:00.000Z"; m.recorded_at = m.session.completed_at; } }, + { expect: "at_evidence_unverified", name: "fake_at", mutate: (m) => { findPass(m, "action-focused", "ax").channel = "at"; } }, + { expect: "runtime_manifest_identity_mismatch", name: "run_identity_mismatch", mutate: (m) => { findPass(m, "action-focused", "dom").run.id = "different-run"; } }, + { expect: "capture_source_drift", name: "source_drift", mutate: (_m, _a, root) => { fs.appendFileSync(path.join(root, "profiles/editorial/tokens.dtcg.json"), "\n"); } }, +]; + +const base = prepareCanonical(); +const alternate = prepareCanonical(); +const results = cases.map((testCase) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `stylegallery-state-artifacts-${testCase.name}-`)); + try { + fs.cpSync(base.root, root, { recursive: true }); + const artifactRoot = path.join(root, "artifacts"); + const manifestFile = path.join(artifactRoot, "runtime-manifest.json"); + const manifest = JSON.parse(fs.readFileSync(manifestFile)); + testCase.mutate?.(manifest, artifactRoot, root); + writeJson(manifestFile, manifest); + const child = spawnSync(process.execPath, [validator, "--root", path.join(root, "profiles"), "--artifact-root", artifactRoot, "--runtime-manifest", manifestFile, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + const output = JSON.parse(child.stdout); + const actual = codes(output); + const ok = testCase.valid ? child.status === 0 && output.ok === true : child.status !== 0 && output.ok === false && actual.includes(testCase.expect); + return { + actual: { + codes: actual, + ...(testCase.valid && !ok ? { findings: output.failures.slice(0, 10) } : {}), + status: child.status, + }, + expected: testCase.expect ?? "valid", + name: testCase.name, + ok, + }; + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +const closedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-state-artifacts-closed-")); +fs.cpSync(base.root, closedRoot, { recursive: true }); +fs.writeFileSync(path.join(closedRoot, "artifacts/runtime/unmanifested.txt"), "rogue"); +const closed = spawnSync(process.execPath, [finalizer, "--root", path.join(closedRoot, "profiles"), "--artifact-root", path.join(closedRoot, "artifacts"), "--json"], { cwd: repositoryRoot, encoding: "utf8" }); +const closedOutput = JSON.parse(closed.stdout); +results.push({ actual: { codes: codes(closedOutput), status: closed.status }, expected: "runtime_artifact_unmanifested", name: "unmanifested_closed_set", ok: closed.status !== 0 && codes(closedOutput).includes("runtime_artifact_unmanifested") }); +results.push({ actual: { codes: codes(closedOutput), status: closed.status }, expected: "capture_session_replay", name: "session_replay", ok: closed.status !== 0 && codes(closedOutput).includes("capture_session_replay") }); +fs.rmSync(closedRoot, { force: true, recursive: true }); + +const revisionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-state-revision-")); +try { + const receiptFile = path.join(revisionRoot, "capture-session.json"); + const child = spawnSync(process.execPath, [creator, "--root", sourceProfiles, "--output", receiptFile, "--json"], { + cwd: repositoryRoot, + encoding: "utf8", + env: { ...process.env, GITHUB_SHA: "0".repeat(40) }, + }); + const output = JSON.parse(child.stdout); + results.push({ + actual: { codes: codes(output), receiptWritten: fs.existsSync(receiptFile), status: child.status }, + expected: "capture_revision_mismatch", + name: "github_sha_mismatch", + ok: child.status !== 0 && codes(output).includes("capture_revision_mismatch") && !fs.existsSync(receiptFile), + }); +} finally { + fs.rmSync(revisionRoot, { force: true, recursive: true }); +} +fs.rmSync(base.root, { force: true, recursive: true }); +fs.rmSync(alternate.root, { force: true, recursive: true }); + +const failures = results.filter((result) => !result.ok).map((result) => `${result.name}:${result.expected}`); +const report = { adversarial: results.length - 1, failures, ok: failures.length === 0, results }; +console.log(JSON.stringify(report, null, 2)); +if (!report.ok) process.exitCode = 1; diff --git a/scripts/test-validate-component-state.mjs b/scripts/test-validate-component-state.mjs new file mode 100644 index 0000000..71a0495 --- /dev/null +++ b/scripts/test-validate-component-state.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceRoot = path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local"); +const validator = path.join(repositoryRoot, "scripts/validate-component-state.mjs"); + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function writeJson(file, value) { + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function edit(root, profile, relative, mutate) { + const file = path.join(root, profile, relative); + const value = readJson(file); + mutate(value); + writeJson(file, value); +} + +function scenario(value, id) { + return value.scenarios.find((candidate) => candidate.id === id); +} + +function pass(value, scenarioId, channel) { + return value.passes.find((candidate) => candidate.scenario_id === scenarioId && candidate.channel === channel); +} + +function run(root) { + const child = spawnSync(process.execPath, [validator, "--root", root, "--json"], { cwd: repositoryRoot, encoding: "utf8" }); + let report = { failures: [], ok: false, parse_error: true }; + try { report = JSON.parse(child.stdout); } catch { report = { failures: [], ok: false, parse_error: true }; } + return { report, status: child.status }; +} + +const cases = [ + ["unresolved_conditional", "conditional_unresolved", (root) => edit(root, "editorial", "components/button.component.json", (value) => { delete value.semantic_modes[0].aria_states[2].resolution; })], + ["missing_applicability_reason", "applicability_reason_required", (root) => edit(root, "editorial", "components/button.component.json", (value) => { delete value.semantic_modes[0].aria_states[0].reason; })], + ["missing_role_required_state", "role_required_aria_state", (root) => edit(root, "editorial", "components/button.component.json", (value) => { value.semantic_modes[1].aria_states = value.semantic_modes[1].aria_states.filter((state) => state.name !== "pressed"); })], + ["switch_mixed", "switch_mixed_prohibited", (root) => edit(root, "editorial", "components/button.component.json", (value) => { value.semantic_modes[1] = { ...value.semantic_modes[1], aria_states: [{ name: "checked", status: "required", values: ["false", "mixed"] }], id: "switch", role: "switch" }; })], + ["missing_environment", "evidence_schema_invalid", (root) => edit(root, "editorial", "evidence/button.evidence.json", (value) => { delete value.passes[0].environment; })], + ["committed_visual_expectation_mismatch", "evidence_visual_expectation_mismatch", (root) => edit(root, "editorial", "evidence/button.evidence.json", (value) => { pass(value, "action-disabled-busy", "visual").artifact.sha256 = `sha256:${"0".repeat(64)}`; })], + ["missing_committed_source", "capture_source_missing", (root) => edit(root, "editorial", "evidence/button.evidence.json", (value) => { delete value.passes[0].session.source; })], + ["committed_source_drift", "capture_source_drift", (root) => fs.appendFileSync(path.join(root, "editorial/tokens.dtcg.json"), "\n")], + ["false_aggregate_pass", "aggregate_pass_forbidden", (root) => edit(root, "editorial", "evidence/button.evidence.json", (value) => { value.aggregate_pass = true; })], + ["normative_conflict", "normative_activation_conflict", (root) => edit(root, "editorial", "states/button.states.json", (value) => { scenario(value, "action-focused").expected.activation = "suppressed"; })], + ["preview_rolled_stable", "preview_rolled_stable", (root) => edit(root, "editorial", "components/button.component.json", (value) => { value.versions.delivery_channel = "stable"; })], + ["expanded_surface", "expanded_surface_mismatch", (root) => edit(root, "editorial", "states/button.states.json", (value) => { scenario(value, "disclosure-expanded-loading").expected.ax.expanded = false; })], + ["disabled_activation", "disabled_activation", (root) => edit(root, "editorial", "states/button.states.json", (value) => { scenario(value, "action-disabled-busy").expected.activation = "allowed"; })], + ["loading_without_busy", "loading_busy_state_required", (root) => edit(root, "editorial", "states/button.states.json", (value) => { scenario(value, "action-loading-busy").states = ["focus", "loading"]; })], + ["loading_as_disabled", "loading_disabled_conflated", (root) => edit(root, "editorial", "states/button.states.json", (value) => { scenario(value, "action-loading-busy").expected.visual.push("disabled"); })], + ["required_channel", "required_channel_missing", (root) => edit(root, "editorial", "evidence/button.evidence.json", (value) => { value.passes = value.passes.filter((candidate) => !(candidate.scenario_id === "action-focused" && candidate.channel === "ax")); })], + ["self_asserted_at", "at_evidence_unverified", (root) => edit(root, "editorial", "evidence/button.evidence.json", (value) => { value.passes[0].channel = "at"; value.passes[0].environment.kind = "assistive_technology"; })], + ["duplicate_mode", "semantic_mode_duplicate", (root) => edit(root, "editorial", "components/button.component.json", (value) => { value.semantic_modes.push(structuredClone(value.semantic_modes[0])); })], + ["duplicate_pass", "evidence_pass_duplicate", (root) => edit(root, "editorial", "evidence/button.evidence.json", (value) => { value.passes.push(structuredClone(value.passes[0])); })], + ["duplicate_state_set", "state_set_duplicate", (root) => edit(root, "editorial", "states/button.states.json", (value) => { const copy = structuredClone(value.scenarios[0]); copy.id = "duplicate-disabled-busy"; copy.states.reverse(); value.scenarios.push(copy); })], + ["duplicate_visual_environment", "visual_environment_duplicate", (root) => edit(root, "editorial", "states/button.states.json", (value) => { value.visual_environments.push(structuredClone(value.visual_environments[0])); })], + ["missing_visual_expectation", "visual_expectation_missing", (root) => edit(root, "editorial", "states/button.states.json", (value) => { scenario(value, "action-focused").expected.visual_image.pop(); })], + ["unknown_visual_environment", "visual_environment_unknown", (root) => edit(root, "editorial", "states/button.states.json", (value) => { scenario(value, "action-focused").expected.visual_image[0].environment_id = "unknown-environment"; })], + ["pressed_surface", "pressed_surface_mismatch", (root) => edit(root, "editorial", "states/button.states.json", (value) => { scenario(value, "toggle-focused-pressed").expected.dom["aria-pressed"] = "false"; })], + ["busy_surface", "busy_surface_mismatch", (root) => edit(root, "editorial", "states/button.states.json", (value) => { scenario(value, "action-loading-busy").expected.ax.busy = false; })], + ["disabled_surface", "disabled_surface_mismatch", (root) => edit(root, "editorial", "states/button.states.json", (value) => { scenario(value, "action-disabled-busy").expected.dom.disabled = "false"; })], + ["unknown_evidence_scenario", "evidence_scenario_unknown", (root) => edit(root, "editorial", "evidence/button.evidence.json", (value) => { value.passes[0].scenario_id = "unknown-scenario"; })], + ["duplicate_channel", "evidence_channel_duplicate", (root) => edit(root, "editorial", "evidence/button.evidence.json", (value) => { const copy = structuredClone(value.passes[0]); copy.id = "unique-pass-id"; copy.artifact.path = "runtime/unique.png"; copy.artifact.sha256 = `sha256:${"1".repeat(64)}`; value.passes.push(copy); })], + ["activation_key", "fixture_activation_key_invalid", (root) => edit(root, "editorial", "fixtures/button.fixture.json", (value) => { value.scenarios[1].activation_key = "Escape"; })], + ["unexercised_scenario", "fixture_scenario_unexercised", (root) => edit(root, "editorial", "fixtures/button.fixture.json", (value) => { value.scenarios = value.scenarios.filter((candidate) => candidate.id !== "action-loading-busy"); })], + ["profile_field_missing", "profile_reference_required", (root) => edit(root, "editorial", "profile.json", (value) => { delete value.component_records; })], + ["profile_reference_duplicate", "profile_reference_duplicate", (root) => edit(root, "editorial", "profile.json", (value) => { value.component_records.push(value.component_records[0]); })], + ["profile_reference_unknown", "profile_reference_unknown", (root) => edit(root, "editorial", "profile.json", (value) => { value.component_records[0] = "mystery/button.json"; })], + ["profile_reference_outside", "profile_reference_outside", (root) => edit(root, "editorial", "profile.json", (value) => { value.component_records[0] = "../terminal/components/button.component.json"; })], + ["profile_reference_missing", "profile_reference_missing", (root) => fs.rmSync(path.join(root, "editorial/components/button.component.json"))], + ["profile_reference_symlink", "profile_reference_symlink", (root) => { const file = path.join(root, "editorial/components/button.component.json"); fs.rmSync(file); fs.symlinkSync(path.join(root, "terminal/components/button.component.json"), file); }], + ["profile_reference_redirect", "profile_reference_redirect", (root) => { const directory = path.join(root, "editorial/components"); const actual = path.join(root, "editorial/components-real"); fs.renameSync(directory, actual); fs.symlinkSync(actual, directory); }], + ["cross_profile_swap", "profile_identity_mismatch", (root) => fs.copyFileSync(path.join(root, "terminal/components/button.component.json"), path.join(root, "editorial/components/button.component.json"))], + ["generated_reference_missing", "profile_reference_missing", (root) => fs.rmSync(path.join(root, "editorial/generated/state-matrix.md"))], +]; + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-pr5-state-")); +const results = []; +try { + for (const [name, expected, mutate] of cases) { + const root = path.join(tempRoot, name); + fs.cpSync(sourceRoot, root, { recursive: true }); + mutate(root); + const child = run(root); + const codes = child.report.failures.map((failure) => failure.code); + results.push({ actual: { codes, scaffold: child.report.scaffold === true, status: child.status }, expected, name, ok: child.status > 0 && codes.includes(expected) && child.report.scaffold !== true }); + } + const canonical = run(sourceRoot); + results.push({ actual: { status: canonical.status }, expected: "canonical_valid", name: "canonical", ok: canonical.status === 0 && canonical.report.ok === true && canonical.report.scaffold !== true }); +} finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); +} + +const failures = results.filter((result) => !result.ok).map((result) => `missing_semantic:${result.name}:${result.expected}`); +const report = { failures, ok: failures.length === 0, results }; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +assert.equal(report.ok, true); diff --git a/scripts/test-validate-consumer-conformance.mjs b/scripts/test-validate-consumer-conformance.mjs new file mode 100644 index 0000000..c8d5b29 --- /dev/null +++ b/scripts/test-validate-consumer-conformance.mjs @@ -0,0 +1,427 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + endToEndCaseNames, + runEndToEndCase, +} from "../consumer-reference/fixtures/consumer-conformance/e2e-fixture.mjs"; +import { MIGRATION_DIMENSIONS, validateConsumerConformanceSemantics } from "./consumer-conformance-contract.mjs"; +import { canonicalSourceManifest } from "./page-evidence-contract.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const fixtureRoot = path.join(repositoryRoot, "consumer-reference", "fixtures", "consumer-conformance"); +const validator = path.join(repositoryRoot, "scripts", "validate-consumer-conformance.mjs"); +const contractCases = Object.freeze([ + ["omitted-dimension", "migration_dimension_required"], + ["applicable-without-scenario", "migration_dimension_scenario_required"], + ["source-regex-runtime-proof", "runtime_evidence_method_invalid"], + ["orphan-adoption-scenario", "adoption_scenario_unknown"], + ["malformed-local-target", "adoption_consumer_target_invalid"], + ["unpinned-stylegallery-revision", "adoption_stylegallery_revision_unpinned"], + ["unknown-stylegallery-revision", "adoption_stylegallery_revision_unknown"], + ["missing-stylegallery-path", "adoption_stylegallery_path_missing"], + ["missing-stylegallery-anchor", "adoption_stylegallery_anchor_missing"], + ["debt-without-lifecycle", "adoption_debt_incomplete"], +]); +const runtimeCases = Object.freeze([ + ["missing-source", "consumer_source_missing"], + ["symlink-source", "consumer_source_symlink"], + ["checkout-symlink", "runtime_command_checkout_symlink"], + ["missing-result-artifact", "runtime_result_artifact_missing"], + ["symlink-result-artifact", "runtime_result_artifact_symlink"], + ["invalid-result-artifact-json", "runtime_result_artifact_json_invalid"], + ["fabricated-result-artifact", "runtime_result_artifact_mismatch"], + ["fabricated-repository", "consumer_repository_mismatch"], + ["fabricated-revision", "consumer_revision_unknown"], + ["fabricated-source-digest", "runtime_source_digest_mismatch"], + ["nonzero-command", "runtime_command_failed"], + ["forged-noop-result", "runtime_command_result_missing"], + ["github-repository-spoof", "consumer_repository_mismatch", { GITHUB_REPOSITORY: "example/spoof" }], +]); +const documentedLifecycleCase = "documented-page-evidence-lifecycle"; + +function parseArguments(argv) { + const options = { caseName: undefined, json: false }; + for (let index = 2; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--json") options.json = true; + else if (argument === "--case") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error("--case requires a case name"); + options.caseName = value; + index += 1; + } else throw new Error(`unsupported argument ${argument}`); + } + return options; +} + +function sameSet(actual, expected) { + return actual.length === expected.length && new Set(actual).size === expected.length && expected.every((entry) => actual.includes(entry)); +} + +function readJson(file) { + return parseStrictJson(fs.readFileSync(file, "utf8")); +} + +function validateInventory(manifest) { + const expectedKeys = ["id", "invalid_cases", "schema_record", "schema_version", "valid_records"]; + const encodedCases = Array.isArray(manifest.invalid_cases) + ? manifest.invalid_cases.map((entry) => Object.keys(entry).length === 2 ? `${entry.name}:${entry.expected_code}` : "") + : []; + const expectedEncoded = [...contractCases, ...runtimeCases].map(([name, code]) => `${name}:${code}`); + const entries = fs.readdirSync(fixtureRoot, { withFileTypes: true }); + const closedFiles = entries.every((entry) => entry.isFile()) + && sameSet(entries.map((entry) => entry.name), ["e2e-fixture.mjs", "manifest.json", "runtime-proof.mjs", "valid-migration-result.json", "valid-migration.json"]); + return Object.keys(manifest).length === expectedKeys.length + && sameSet(Object.keys(manifest), expectedKeys) + && manifest.id === "consumer-conformance-fixture-inventory" + && manifest.schema_version === "1.0" + && manifest.schema_record === "consumer-reference/schema/consumer-conformance-record.schema.json" + && Array.isArray(manifest.valid_records) + && sameSet(manifest.valid_records, ["valid-migration.json"]) + && sameSet(encodedCases, expectedEncoded) + && closedFiles; +} + +function git(root, ...args) { + return execFileSync("git", ["-c", `safe.directory=${root}`, ...args], { cwd: root, encoding: "utf8" }).trim(); +} + +function writeFile(root, reference, content) { + const file = path.join(root, reference); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +function runtimeResult(value, overrides = {}) { + const scenario = value.scenarios[0]; + return { + argv: scenario.argv, + evidence_method: scenario.evidence_method, + exit_code: scenario.exit_code, + record_kind: "consumer_migration_scenario_result", + recorded_at: "2026-07-21T00:00:00Z", + repository: value.consumer.repository, + revision: value.consumer.revision, + run_id: scenario.run_id, + scenario_id: scenario.id, + schema_version: "1.0", + session_id: scenario.session_id, + source_digest: scenario.source_digest, + status: "passed", + ...overrides, + }; +} + +function initializeRuntimeFixture(source) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-consumer-runtime-"))); + execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "ignore" }); + git(root, "config", "user.email", "fixture@example.com"); + git(root, "config", "user.name", "StyleGallery fixture"); + git(root, "remote", "add", "origin", "https://github.com/example/consumer-web.git"); + writeFile(root, "src/app.js", "export const app = true;\n"); + writeFile(root, "src/styles.css", ".app { display: block; }\n"); + writeFile(root, "tests/migration.mjs", `import fs from "node:fs";\nimport path from "node:path";\nimport { spawnSync } from "node:child_process";\nif (process.argv.includes("--lock-worktree")) { const locked = spawnSync("git", ["worktree", "lock", "."]); if (locked.status !== 0) process.exit(18); }\nconst reference = process.env.STYLEGALLERY_RESULT_ARTIFACT;\nconst context = JSON.parse(process.env.STYLEGALLERY_RUNTIME_CONTEXT);\nconst file = path.resolve(process.cwd(), reference);\nfs.mkdirSync(path.dirname(file), { recursive: true });\nif (process.argv.includes("--oversized-result")) { const descriptor = fs.openSync(file, "wx"); fs.ftruncateSync(descriptor, 67108865); fs.closeSync(descriptor); process.exit(0); }\nfs.writeFileSync(file, JSON.stringify({ ...context, record_kind: "consumer_migration_scenario_result", recorded_at: "2026-07-21T00:00:00Z", schema_version: "1.0", status: "passed" }, null, 2) + "\\n", { flag: "wx" });\n`); + writeFile(root, "tests/failing.mjs", "process.exit(17);\n"); + writeFile(root, "tests/hanging.mjs", "setInterval(() => {}, 1000);\n"); + writeFile(root, "tests/noop.mjs", "// Intentionally produces no result artifact.\n"); + git(root, "add", "src/app.js", "src/styles.css", "tests/migration.mjs", "tests/failing.mjs", "tests/hanging.mjs", "tests/noop.mjs"); + git(root, "commit", "-m", "fixture sources"); + const cleanRevision = git(root, "rev-parse", "HEAD"); + fs.symlinkSync("app.js", path.join(root, "src/linked.js")); + git(root, "add", "src/linked.js"); + git(root, "commit", "-m", "fixture symlink source"); + const symlinkRevision = git(root, "rev-parse", "HEAD"); + git(root, "reset", "--hard", cleanRevision); + + const value = structuredClone(source); + value.consumer = { + relevant_sources: ["src/app.js", "src/styles.css", "tests/migration.mjs"], + repository: "example/consumer-web", + revision: git(root, "rev-parse", "HEAD"), + }; + const styleGalleryRevision = git(repositoryRoot, "rev-parse", "HEAD"); + for (const mapping of value.adoption_mappings) mapping.stylegallery.revision = styleGalleryRevision; + const sourceFailures = []; + const manifest = canonicalSourceManifest(root, value.consumer.relevant_sources, sourceFailures); + if (!manifest || sourceFailures.length > 0) throw new Error(`runtime fixture source manifest failed: ${JSON.stringify(sourceFailures)}`); + value.scenarios[0] = { + ...value.scenarios[0], + argv: ["node", "tests/migration.mjs"], + evidence_method: "integration", + result_artifact: "evidence/results/migration-round-trip.json", + source_digest: manifest.sha256, + }; + writeFile(root, value.scenarios[0].result_artifact, `${JSON.stringify(runtimeResult(value), null, 2)}\n`); + writeFile(root, "evidence/results/fabricated.json", `${JSON.stringify(runtimeResult(value, { scenario_id: "fabricated-scenario" }), null, 2)}\n`); + const failing = structuredClone(value); + failing.scenarios[0].argv = ["node", "tests/failing.mjs"]; + failing.scenarios[0].result_artifact = "evidence/results/nonzero-command.json"; + writeFile(root, failing.scenarios[0].result_artifact, `${JSON.stringify(runtimeResult(failing), null, 2)}\n`); + const noop = structuredClone(value); + noop.scenarios[0].argv = ["node", "tests/noop.mjs"]; + noop.scenarios[0].result_artifact = "evidence/results/forged-noop-result.json"; + writeFile(root, noop.scenarios[0].result_artifact, `${JSON.stringify(runtimeResult(noop), null, 2)}\n`); + writeFile(root, "evidence/results/invalid.json", "{not json}\n"); + fs.symlinkSync("migration-round-trip.json", path.join(root, "evidence/results/redirect.json")); + return { root, symlinkRevision, value }; +} + +function mutatedRuntimeRecord(name, source, fixture) { + const value = structuredClone(source); + if (name === "missing-source") value.consumer.relevant_sources[0] = "src/missing.js"; + else if (name === "symlink-source") { + value.consumer.relevant_sources[0] = "src/linked.js"; + value.consumer.revision = fixture.symlinkRevision; + } + else if (name === "checkout-symlink") value.consumer.revision = fixture.symlinkRevision; + else if (name === "missing-result-artifact") value.scenarios[0].result_artifact = "evidence/results/missing.json"; + else if (name === "symlink-result-artifact") value.scenarios[0].result_artifact = "evidence/results/redirect.json"; + else if (name === "invalid-result-artifact-json") value.scenarios[0].result_artifact = "evidence/results/invalid.json"; + else if (name === "fabricated-result-artifact") value.scenarios[0].result_artifact = "evidence/results/fabricated.json"; + else if (name === "fabricated-repository") value.consumer.repository = "fabricated/consumer"; + else if (name === "fabricated-revision") value.consumer.revision = "0".repeat(40); + else if (name === "fabricated-source-digest") value.scenarios[0].source_digest = "0".repeat(64); + else if (name === "nonzero-command") { + value.scenarios[0].argv = ["node", "tests/failing.mjs"]; + value.scenarios[0].result_artifact = "evidence/results/nonzero-command.json"; + } + else if (name === "forged-noop-result") { + value.scenarios[0].argv = ["node", "tests/noop.mjs"]; + value.scenarios[0].result_artifact = "evidence/results/forged-noop-result.json"; + } + else if (name === "github-repository-spoof") value.consumer.repository = "example/spoof"; + else throw new Error(`unknown runtime fixture case ${name}`); + return value; +} + +function documentedLifecycleIsExecutable() { + const document = fs.readFileSync(path.join(repositoryRoot, "design-engineering", "consumer-migration-readiness.md"), "utf8"); + const start = document.indexOf("## Verification Contract"); + const end = document.indexOf("## Source, License, And Attribution"); + const verification = document.slice(start, end); + return [ + "create-page-evidence-session.mjs", + "finalize-page-evidence.mjs", + "validate-consumer-conformance.mjs", + "--root ", + "--record ", + "--artifact-root ", + "--runner-result ", + "--review-by ", + ].every((fragment) => verification.includes(fragment)); +} + +function mutatedRecord(name, source) { + const value = structuredClone(source); + if (name === "omitted-dimension") delete value.migration_dimensions.exact_time_boundary; + else if (name === "applicable-without-scenario") value.migration_dimensions.route_parity = { scenario_ids: [], status: "applicable" }; + else if (name === "source-regex-runtime-proof") value.scenarios[0].evidence_method = "source_regex"; + else if (name === "orphan-adoption-scenario") value.adoption_mappings[0].scenario_ids = ["missing-scenario"]; + else if (name === "malformed-local-target") value.adoption_mappings[0].consumer_target = { identity: "app shell", kind: "selector" }; + else if (name === "unpinned-stylegallery-revision") value.adoption_mappings[0].stylegallery.revision = "abc123"; + else if (name === "unknown-stylegallery-revision") value.adoption_mappings[0].stylegallery.revision = "0".repeat(40); + else if (name === "missing-stylegallery-path") value.adoption_mappings[0].stylegallery.path = "recipes/missing.md"; + else if (name === "missing-stylegallery-anchor") value.adoption_mappings[0].stylegallery.anchor = "#missing-anchor"; + else if (name === "debt-without-lifecycle") value.adoption_mappings[0].debt = [{ summary: "Replace the temporary spacing bridge." }]; + else throw new Error(`unknown fixture case ${name}`); + return value; +} + +function runValidator(root, record, env = {}) { + const child = spawnSync(process.execPath, [validator, "--root", root, "--record", record, "--json"], { + cwd: repositoryRoot, + encoding: "utf8", + env: { ...process.env, ...env }, + }); + let output; + try { + output = JSON.parse(child.stdout); + } catch { + output = { failures: [], parse_error: child.stderr || child.stdout }; + } + return { output, status: child.status }; +} + +const options = parseArguments(process.argv); +const tempRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-consumer-conformance-"))); +const results = []; +let runtimeFixture; +try { + const manifest = readJson(path.join(fixtureRoot, "manifest.json")); + const inventoryOk = validateInventory(manifest) && fs.existsSync(path.join(repositoryRoot, manifest.schema_record)); + results.push({ actual: inventoryOk, expected: true, name: "closed-fixture-inventory", ok: inventoryOk }); + const valid = readJson(path.join(fixtureRoot, "valid-migration.json")); + const selected = options.caseName ?? "all"; + for (const name of endToEndCaseNames) { + if (selected === "all" || selected === name) results.push(runEndToEndCase(name, valid)); + } + if (selected === "all" || selected === "valid-migration") { + const codes = validateConsumerConformanceSemantics(valid, "valid-migration.json").map((entry) => entry.code); + const checkedDimensions = Object.keys(valid.migration_dimensions ?? {}).length; + const checkedMappings = valid.adoption_mappings?.length ?? 0; + const ok = codes.length === 0 && checkedDimensions === MIGRATION_DIMENSIONS.length && checkedMappings === 1; + results.push({ actual: { checkedDimensions, checkedMappings, codes }, expected: "valid static template, 13 dimensions, one mapping", name: "valid-migration", ok }); + } + if (selected === "all" || selected === "stale-template-rebound") { + const stale = structuredClone(valid); + stale.adoption_mappings[0].stylegallery.revision = "0".repeat(40); + const result = runEndToEndCase("end-to-end-consumer", stale); + results.push({ ...result, expected: "dynamic record rebound to the current StyleGallery checkout", name: "stale-template-rebound" }); + } + const contractTemplate = structuredClone(valid); + const styleGalleryRevision = git(repositoryRoot, "rev-parse", "HEAD"); + for (const mapping of contractTemplate.adoption_mappings) mapping.stylegallery.revision = styleGalleryRevision; + for (const [name, expectedCode] of contractCases) { + if (selected !== "all" && selected !== name) continue; + const record = path.join(tempRoot, `${name}.json`); + fs.writeFileSync(record, `${JSON.stringify(mutatedRecord(name, contractTemplate), null, 2)}\n`); + const child = runValidator(tempRoot, path.basename(record)); + const codes = child.output.failures?.map((entry) => entry.code) ?? []; + const ok = child.status !== 0 && codes.includes(expectedCode); + results.push({ actual: { codes, status: child.status }, expected: expectedCode, name, ok }); + } + const runtimeTemplate = structuredClone(valid); + runtimeTemplate.adoption_mappings[0].stylegallery.revision = "0".repeat(40); + runtimeFixture = initializeRuntimeFixture(runtimeTemplate); + if (selected === "all" || selected === "valid-runtime-proof") { + const record = "records/valid-runtime-proof.json"; + writeFile(runtimeFixture.root, record, `${JSON.stringify(runtimeFixture.value, null, 2)}\n`); + const child = runValidator(runtimeFixture.root, record); + const ok = child.status === 0 && child.output.ok === true && child.output.checkedScenarios === 1; + results.push({ actual: { codes: child.output.failures?.map((entry) => entry.code) ?? [], status: child.status }, expected: "runtime-bound record and exit:0", name: "valid-runtime-proof", ok }); + } + for (const [name, expectedCode, env] of runtimeCases) { + if (selected !== "all" && selected !== name) continue; + const record = `records/${name}.json`; + writeFile(runtimeFixture.root, record, `${JSON.stringify(mutatedRuntimeRecord(name, runtimeFixture.value, runtimeFixture), null, 2)}\n`); + const child = runValidator(runtimeFixture.root, record, env); + const codes = child.output.failures?.map((entry) => entry.code) ?? []; + const ok = child.status !== 0 && codes.includes(expectedCode); + results.push({ actual: { codes, status: child.status }, expected: expectedCode, name, ok }); + } + if (selected === "all" || selected === "child-process-denied-cleanup") { + const value = structuredClone(runtimeFixture.value); + value.scenarios[0].argv = ["node", "tests/migration.mjs", "--lock-worktree"]; + value.scenarios[0].result_artifact = "evidence/results/locked-worktree-cleanup.json"; + writeFile(runtimeFixture.root, value.scenarios[0].result_artifact, `${JSON.stringify(runtimeResult(value), null, 2)}\n`); + const record = "records/locked-worktree-cleanup.json"; + writeFile(runtimeFixture.root, record, `${JSON.stringify(value, null, 2)}\n`); + const before = git(runtimeFixture.root, "worktree", "list", "--porcelain"); + const child = runValidator(runtimeFixture.root, record); + const after = git(runtimeFixture.root, "worktree", "list", "--porcelain"); + const codes = child.output.failures?.map((entry) => entry.code) ?? []; + const ok = child.status !== 0 && codes.includes("runtime_command_failed") && before === after; + results.push({ actual: { codes, registeredWorktreesUnchanged: before === after, status: child.status }, expected: "child process denied and no registered temporary worktree", name: "child-process-denied-cleanup", ok }); + } + if (selected === "all" || selected === "timed-out-command-cleanup") { + const value = structuredClone(runtimeFixture.value); + value.scenarios[0].argv = ["node", "tests/hanging.mjs"]; + value.scenarios[0].result_artifact = "evidence/results/timed-out-command.json"; + writeFile(runtimeFixture.root, value.scenarios[0].result_artifact, `${JSON.stringify(runtimeResult(value), null, 2)}\n`); + const record = "records/timed-out-command.json"; + writeFile(runtimeFixture.root, record, `${JSON.stringify(value, null, 2)}\n`); + const before = git(runtimeFixture.root, "worktree", "list", "--porcelain"); + const child = runValidator(runtimeFixture.root, record, { STYLEGALLERY_RUNTIME_TIMEOUT_MS: "100" }); + const after = git(runtimeFixture.root, "worktree", "list", "--porcelain"); + const codes = child.output.failures?.map((entry) => entry.code) ?? []; + const ok = child.status !== 0 && codes.includes("runtime_command_failed") && before === after; + results.push({ actual: { codes, registeredWorktreesUnchanged: before === after, status: child.status }, expected: "timeout and no registered temporary worktree", name: "timed-out-command-cleanup", ok }); + } + if (selected === "all" || selected === "permission-override-denied") { + const value = structuredClone(runtimeFixture.value); + value.scenarios[0].argv = ["node", "--allow-child-process", "tests/migration.mjs"]; + value.scenarios[0].result_artifact = "evidence/results/permission-override.json"; + writeFile(runtimeFixture.root, value.scenarios[0].result_artifact, `${JSON.stringify(runtimeResult(value), null, 2)}\n`); + const record = "records/permission-override.json"; + writeFile(runtimeFixture.root, record, `${JSON.stringify(value, null, 2)}\n`); + const child = runValidator(runtimeFixture.root, record); + const codes = child.output.failures?.map((entry) => entry.code) ?? []; + const ok = child.status !== 0 && codes.includes("runtime_command_argv_unsafe") && child.output.checkedRuntimeCommands === 0; + results.push({ actual: { checkedRuntimeCommands: child.output.checkedRuntimeCommands, codes, status: child.status }, expected: "permission override rejected before execution", name: "permission-override-denied", ok }); + } + if (selected === "all" || selected === "permission-disable-denied") { + const value = structuredClone(runtimeFixture.value); + value.scenarios[0].argv = ["node", "--no-permission=false", "tests/migration.mjs"]; + value.scenarios[0].result_artifact = "evidence/results/permission-disable.json"; + writeFile(runtimeFixture.root, value.scenarios[0].result_artifact, `${JSON.stringify(runtimeResult(value), null, 2)}\n`); + const record = "records/permission-disable.json"; + writeFile(runtimeFixture.root, record, `${JSON.stringify(value, null, 2)}\n`); + const child = runValidator(runtimeFixture.root, record); + const codes = child.output.failures?.map((entry) => entry.code) ?? []; + const ok = child.status !== 0 && codes.includes("runtime_command_argv_unsafe") && child.output.checkedRuntimeCommands === 0; + results.push({ actual: { checkedRuntimeCommands: child.output.checkedRuntimeCommands, codes, status: child.status }, expected: "permission disable rejected before execution", name: "permission-disable-denied", ok }); + } + if (selected === "all" || selected === "permission-underscore-denied") { + const value = structuredClone(runtimeFixture.value); + value.scenarios[0].argv = ["node", "--allow_child_process", "tests/migration.mjs"]; + value.scenarios[0].result_artifact = "evidence/results/permission-underscore.json"; + writeFile(runtimeFixture.root, value.scenarios[0].result_artifact, `${JSON.stringify(runtimeResult(value), null, 2)}\n`); + const record = "records/permission-underscore.json"; + writeFile(runtimeFixture.root, record, `${JSON.stringify(value, null, 2)}\n`); + const child = runValidator(runtimeFixture.root, record); + const codes = child.output.failures?.map((entry) => entry.code) ?? []; + const ok = child.status !== 0 && codes.includes("runtime_command_argv_unsafe") && child.output.checkedRuntimeCommands === 0; + results.push({ actual: { checkedRuntimeCommands: child.output.checkedRuntimeCommands, codes, status: child.status }, expected: "underscore permission override rejected before execution", name: "permission-underscore-denied", ok }); + } + if (selected === "all" || selected === "permission-path-shaped-denied") { + const value = structuredClone(runtimeFixture.value); + value.scenarios[0].argv = ["node", "--allow-fs-read=/tmp/outside.mjs", "tests/migration.mjs"]; + value.scenarios[0].result_artifact = "evidence/results/permission-path-shaped.json"; + writeFile(runtimeFixture.root, value.scenarios[0].result_artifact, `${JSON.stringify(runtimeResult(value), null, 2)}\n`); + const record = "records/permission-path-shaped.json"; + writeFile(runtimeFixture.root, record, `${JSON.stringify(value, null, 2)}\n`); + const child = runValidator(runtimeFixture.root, record); + const codes = child.output.failures?.map((entry) => entry.code) ?? []; + const ok = child.status !== 0 && codes.includes("runtime_command_argv_unsafe") && child.output.checkedRuntimeCommands === 0; + results.push({ actual: { checkedRuntimeCommands: child.output.checkedRuntimeCommands, codes, status: child.status }, expected: "path-shaped permission option rejected before execution", name: "permission-path-shaped-denied", ok }); + } + if (selected === "all" || selected === "oversized-command-result") { + const value = structuredClone(runtimeFixture.value); + value.scenarios[0].argv = ["node", "tests/migration.mjs", "--oversized-result"]; + value.scenarios[0].result_artifact = "evidence/results/oversized-command-result.json"; + writeFile(runtimeFixture.root, value.scenarios[0].result_artifact, `${JSON.stringify(runtimeResult(value), null, 2)}\n`); + const record = "records/oversized-command-result.json"; + writeFile(runtimeFixture.root, record, `${JSON.stringify(value, null, 2)}\n`); + const child = runValidator(runtimeFixture.root, record); + const codes = child.output.failures?.map((entry) => entry.code) ?? []; + const ok = child.status !== 0 && codes.includes("runtime_command_result_missing") && child.output.checkedRuntimeCommands === 0; + results.push({ actual: { checkedRuntimeCommands: child.output.checkedRuntimeCommands, codes, status: child.status }, expected: "oversized command result rejected before read", name: "oversized-command-result", ok }); + } + if (selected === "all" || selected === documentedLifecycleCase) { + const actual = documentedLifecycleIsExecutable(); + results.push({ actual, expected: true, name: documentedLifecycleCase, ok: actual }); + } + const knownCase = selected === "all" + || selected === "valid-migration" + || selected === "valid-runtime-proof" + || selected === "stale-template-rebound" + || selected === documentedLifecycleCase + || selected === "child-process-denied-cleanup" + || selected === "timed-out-command-cleanup" + || selected === "permission-override-denied" + || selected === "permission-disable-denied" + || selected === "permission-underscore-denied" + || selected === "permission-path-shaped-denied" + || selected === "oversized-command-result" + || endToEndCaseNames.includes(selected) + || contractCases.some(([name]) => name === selected) + || runtimeCases.some(([name]) => name === selected); + if (!knownCase) { + results.push({ actual: selected, expected: "known case", name: "case-selection", ok: false }); + } +} finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); + if (runtimeFixture) fs.rmSync(runtimeFixture.root, { force: true, recursive: true }); +} + +const result = { ok: results.every((entry) => entry.ok), results }; +if (options.json) console.log(JSON.stringify(result, null, 2)); +else if (result.ok) console.log(`ok: ${results.length} consumer conformance fixture cases`); +else console.error(results.filter((entry) => !entry.ok).map((entry) => `${entry.name}: expected ${entry.expected}`).join("\n")); +process.exitCode = result.ok ? 0 : 1; diff --git a/scripts/test-validate-consumer-reference-cli.mjs b/scripts/test-validate-consumer-reference-cli.mjs new file mode 100644 index 0000000..0d45351 --- /dev/null +++ b/scripts/test-validate-consumer-reference-cli.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const validator = path.join(repositoryRoot, "scripts", "validate-consumer-reference.mjs"); +const validItem = "consumer-reference/fixtures/valid-experimental.json"; +const errorCases = [ + { args: ["--item", "--json"], codes: ["argument_value_required"], name: "item_missing_value_before_json" }, + { args: ["--item", "--unknown", "--json"], codes: ["argument_value_required", "argument_unknown"], name: "item_missing_value_before_unknown" }, + { args: ["--profile", "--json"], codes: ["argument_value_required"], name: "profile_missing_value_before_json" }, + { args: ["--profile", "--unknown", "--json"], codes: ["argument_value_required", "argument_unknown"], name: "profile_missing_value_before_unknown" }, + { args: ["--json", "--item"], codes: ["argument_value_required"], name: "json_before_item_missing_value" }, + { args: ["--json", "--profile"], codes: ["argument_value_required"], name: "json_before_profile_missing_value" }, +]; + +function run(args) { + return spawnSync(process.execPath, [validator, ...args], { + cwd: repositoryRoot, + encoding: "utf8", + }); +} + +function runErrorCase(testCase) { + const child = run(testCase.args); + let report; + try { + report = JSON.parse(child.stdout); + } catch (error) { + return { + actual: { stderr: child.stderr.trim(), status: child.status, stdout: child.stdout }, + expected: `${testCase.codes.join(",")} in parseable JSON with nonzero exit`, + name: testCase.name, + ok: false, + reason: error instanceof Error ? error.message : String(error), + }; + } + const codes = Array.isArray(report.failures) ? report.failures.map((failure) => failure.code) : []; + return { + actual: { codes, ok: report.ok, status: child.status, stderr: child.stderr.trim() }, + expected: `${testCase.codes.join(",")} in parseable JSON with nonzero exit`, + name: testCase.name, + ok: child.status !== 0 + && child.stderr.length === 0 + && report.ok === false + && codes.length === testCase.codes.length + && testCase.codes.every((code) => codes.includes(code)), + }; +} + +const results = errorCases.map(runErrorCase); +const jsonLast = run(["--item", validItem, "--json"]); +const jsonFirst = run(["--json", "--item", validItem]); +results.push({ + actual: { firstStatus: jsonFirst.status, lastStatus: jsonLast.status, stderr: `${jsonFirst.stderr}${jsonLast.stderr}` }, + expected: "valid arguments produce byte-identical JSON regardless of --json position", + name: "valid_json_option_order", + ok: jsonFirst.status === 0 + && jsonLast.status === 0 + && jsonFirst.stderr.length === 0 + && jsonLast.stderr.length === 0 + && jsonFirst.stdout === jsonLast.stdout, +}); + +const report = { ok: results.every((result) => result.ok), results }; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/test-validate-consumer-reference.mjs b/scripts/test-validate-consumer-reference.mjs index 7ce54d1..639b680 100644 --- a/scripts/test-validate-consumer-reference.mjs +++ b/scripts/test-validate-consumer-reference.mjs @@ -1,15 +1,20 @@ #!/usr/bin/env node import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; -import { validateItemSchema } from "./consumer-reference-schema.mjs"; +import { fileURLToPath } from "node:url"; +import { + cleanupCompletedConsumer, + initializeCompletedConsumer, +} from "../consumer-reference/fixtures/consumer-conformance/e2e-fixture.mjs"; +import { makeConsumerReferenceCaseRunner } from "./consumer-reference-case-runner.mjs"; -const repositoryRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const validator = path.join(repositoryRoot, "scripts", "validate-consumer-reference.mjs"); const itemPath = "consumer-reference/fixtures/item.json"; const schema = JSON.parse(fs.readFileSync(path.join(repositoryRoot, "consumer-reference", "schema", "item.schema.json"), "utf8")); +const conformanceTemplate = JSON.parse(fs.readFileSync(path.join(repositoryRoot, "consumer-reference", "fixtures", "consumer-conformance", "valid-migration.json"), "utf8")); const baseItem = { artifact_mode: "schema_only", @@ -31,6 +36,26 @@ const baseItem = { }, }; +function applyGovernedProfile(item) { + Object.assign(item, { + artifact_mode: "governed_local", + component_records: ["components/button.component.json"], + default: false, + environment_assumptions: { reset: { body_margin: "0", box_sizing: "border-box", figure_margin: "0" }, user_agent_styles: "Preserve declared browser defaults." }, + evidence_records: ["evidence/button.evidence.json"], + example_only: true, + fixture_records: ["fixtures/button.fixture.json"], + generated_records: ["generated/evidence-coverage.md", "generated/keyboard-matrix.md", "generated/state-matrix.md"], + layout_source_sha: "775430bbaf4ee208a642220f440f6926d79c90a3", + local_foundations: "local-foundations.json", + profile_kind: "governed_local", + related_fixture_set_id: "layout-identity-adversarial-pair", + selection: { method: "profile_path", required: true }, + state_records: ["states/button.states.json"], + tokens: "tokens.dtcg.json", + }); +} + const behaviorCases = [ { expect: null, name: "valid_declared_handoff" }, { expect: null, mutate: (item) => { item.handoff = { reason: "This fixture has no consumer-specific reference requirement.", status: "not_applicable" }; }, name: "valid_not_applicable_handoff" }, @@ -64,6 +89,69 @@ const behaviorCases = [ { expect: "handoff_consumer_reference_required", extraFiles: { "quality/claim.md": "Implementation handoff:\nBoundary: none.\n" }, name: "repository_handoff_omission" }, ]; +function writeReceiverFile(root, relative, content) { + const file = path.join(root, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +function receiverHandoff(recordLine, declarationLine = "Consumer migration conformance: declared") { + return [ + "Implementation handoff:", + "Consumer reference: not_applicable", + "Consumer reference reason: This fixture has no consumer-specific reference record.", + declarationLine, + recordLine, + "", + ].filter(Boolean).join("\n"); +} + +function runMigrationReceiverCase(testCase) { + const fixture = initializeCompletedConsumer(conformanceTemplate); + try { + writeReceiverFile(fixture.root, itemPath, `${JSON.stringify(baseItem, null, 2)}\n`); + writeReceiverFile(fixture.root, "CATALOG.md", "# Catalog\n"); + writeReceiverFile(fixture.root, "layout/index.md", "# Layout\n"); + writeReceiverFile(fixture.root, "patterns/index.md", "# Patterns\n"); + writeReceiverFile(fixture.root, "scripts/pattern-data.mjs", "export const patterns = [];\n"); + const handoff = testCase.handoff ?? receiverHandoff(`Consumer migration conformance record: ${fixture.recordReference}`); + writeReceiverFile(fixture.root, "quality/handoff.md", handoff); + testCase.mutate?.(fixture); + const child = spawnSync(process.execPath, [validator, "--item", itemPath, "--json"], { cwd: fixture.root, encoding: "utf8" }); + const output = JSON.parse(child.stdout); + const codes = output.failures?.map((failure) => failure.code) ?? []; + const ok = testCase.expect === null + ? child.status === 0 && output.ok === true + : testCase.expect === "checkedMigrationRecords:1" + ? child.status === 0 && output.ok === true && output.checkedMigrationRecords === 1 + : child.status !== 0 && output.ok === false && codes.includes(testCase.expect); + return { actual: { checkedMigrationRecords: output.checkedMigrationRecords, codes, status: child.status }, expected: testCase.expect ?? "ok:true and exit:0", name: testCase.name, ok, rules: [] }; + } finally { + cleanupCompletedConsumer(fixture); + } +} + +const migrationReceiverCases = [ + { expect: "checkedMigrationRecords:1", name: "migration_declared_record_executes" }, + { + expect: "consumer_repository_mismatch", + mutate: (fixture) => { + const file = path.join(fixture.root, fixture.recordReference); + const record = JSON.parse(fs.readFileSync(file, "utf8")); + record.consumer.repository = "fabricated/consumer"; + fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`); + }, + name: "migration_invalid_record_blocks_handoff", + }, + { expect: "migration_conformance_record_unresolved", handoff: receiverHandoff("Consumer migration conformance record: records/missing.json"), name: "migration_missing_record_blocks_handoff" }, + { expect: "migration_conformance_declaration_required", handoff: receiverHandoff("Consumer migration conformance record: records/migration.json", ""), name: "migration_orphan_record_blocks_handoff" }, + { + expect: null, + handoff: ["Implementation handoff:", "Consumer reference: not_applicable", "Consumer reference reason: This ordinary handoff has no consumer-specific reference record.", ""].join("\n"), + name: "ordinary_not_applicable_skips_migration_receiver", + }, +]; + const requiredParityCases = schema.required.map((field) => ({ expect: field === "handoff" ? "handoff_required" : "item_field_required", mutate: (item) => { delete item[field]; }, @@ -114,6 +202,14 @@ const parityCases = [ { expect: "support_status_invalid", mutate: (item) => { item.support.status = "paused"; }, name: "schema_support_status_enum", rules: ["support.status.enum"], schemaValid: false }, { expect: "stable_support_ended", mutate: (item) => { item.maturity = "stable"; item.support.status = "ended"; }, name: "schema_stable_support_all_of", rules: ["allOf.stable_ended"], schemaValid: false }, { expect: "deprecated_migration_required", mutate: (item) => { item.maturity = "deprecated"; }, name: "schema_deprecated_all_of", rules: ["allOf.deprecated_required"], schemaValid: false }, + ...["component_records", "evidence_records", "fixture_records", "generated_records", "state_records"].map((field) => ({ + expect: "profile_field_required", + mutate: (item) => { applyGovernedProfile(item); delete item[field]; }, + name: `schema_governed_required_${field}`, + rules: [`allOf.governed.required.${field}`], + schemaValid: false, + })), + { expect: "profile_reference_array_invalid", mutate: (item) => { applyGovernedProfile(item); item.generated_records[2] = item.generated_records[1]; }, name: "schema_governed_generated_unique", rules: ["generated_records.uniqueItems"], schemaValid: false }, { expect: null, mutate: (item) => { item.maturity = "stable"; }, name: "schema_valid_stable_active", rules: ["maturity.enum", "allOf.stable_ended"], schemaValid: true }, { expect: null, mutate: (item) => { item.handoff = { reason: "This reference is intentionally not applicable.", status: "not_applicable" }; item.maturity = "deprecated"; item.removal_trigger = "Remove after migration completes."; item.replacement = "replacement-item"; item.support.status = "ended"; }, name: "schema_valid_deprecated", rules: ["handoff.not_applicable.required", "handoff.reason.type", "handoff.reason.minLength", "handoff.reason.pattern", "allOf.deprecated_required", "support.status.enum"], schemaValid: true }, ...validArtifactCases, @@ -129,94 +225,12 @@ const expectedSchemaRuleNames = [ "owner.enforcement.const", "owner.name.type", "owner.name.minLength", "removal_trigger.type", "removal_trigger.minLength", "replacement.type", "replacement.minLength", "review_independence.const", "schema_version.const", "support.type", "support.additionalProperties", "support.required.status", "support.status.enum", "allOf.stable_ended", "allOf.deprecated_required", + ...["component_records", "evidence_records", "fixture_records", "generated_records", "state_records"].map((field) => `allOf.governed.required.${field}`), + "generated_records.uniqueItems", ]; -function cloneItem() { - return JSON.parse(JSON.stringify(baseItem)); -} - -function findingCodes(output) { - if (!Array.isArray(output.failures)) return []; - return output.failures.flatMap((failure) => { - if (typeof failure === "string") return [failure]; - return typeof failure?.code === "string" ? [failure.code] : []; - }); -} - -function writeFixture(testCase) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), `stylegallery-consumer-reference-${testCase.name}-`)); - let externalRoot; - const item = Object.hasOwn(testCase, "value") ? structuredClone(testCase.value) : cloneItem(); - testCase.mutate?.(item); - const files = { - "CATALOG.md": "# Catalog\n", - [itemPath]: `${JSON.stringify(item, null, 2)}\n`, - "layout/index.md": testCase.layout ?? "# Layout\n", - "patterns/index.md": "# Patterns\n", - "quality/handoff.md": "Implementation handoff:\nConsumer reference: not_applicable\nConsumer reference reason: This fixture has no consumer-specific reference record.\n", - "scripts/pattern-data.mjs": testCase.patternData ?? "export const patterns = [];\n", - ...(testCase.extraFiles ?? {}), - }; - for (const [relative, content] of Object.entries(files)) { - const target = path.join(root, relative); - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, content); - } - if (testCase.link) { - const link = path.join(root, "consumer-reference", "fixtures", "redirect.json"); - if (testCase.link === "inside") { - fs.symlinkSync(path.join(root, itemPath), link); - } else { - externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-consumer-reference-outside-")); - const external = path.join(externalRoot, "record.json"); - fs.writeFileSync(external, "{}\n"); - fs.symlinkSync(external, link); - } - } - if (testCase.itemLink) { - const itemTarget = path.join(root, itemPath); - fs.rmSync(itemTarget); - if (testCase.itemLink === "inside") { - const actual = path.join(root, "consumer-reference", "fixtures", "actual-item.json"); - fs.writeFileSync(actual, `${JSON.stringify(item, null, 2)}\n`); - fs.symlinkSync(actual, itemTarget); - } else { - externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-consumer-reference-item-outside-")); - const external = path.join(externalRoot, "item.json"); - fs.writeFileSync(external, `${JSON.stringify(item, null, 2)}\n`); - fs.symlinkSync(external, itemTarget); - } - } - return { externalRoot, item, root }; -} - -function runCase(testCase) { - const fixture = writeFixture(testCase); - try { - const child = spawnSync(process.execPath, [validator, "--item", itemPath, "--json"], { - cwd: fixture.root, - encoding: "utf8", - }); - const output = JSON.parse(child.stdout); - const codes = findingCodes(output); - const accepted = child.status === 0 && output.ok && output.scaffold !== true; - const rejected = child.status !== 0 && !output.ok && codes.includes(testCase.expect); - const schemaCodes = validateItemSchema(fixture.item, schema).map((finding) => finding.code); - const schemaParity = testCase.schemaValid === undefined || (schemaCodes.length === 0) === testCase.schemaValid; - return { - actual: { codes, ok: output.ok, scaffold: output.scaffold === true, schemaCodes, status: child.status }, - expected: testCase.expect ?? "ok:true and exit:0", - name: testCase.name, - ok: (testCase.expect === null ? accepted : rejected) && schemaParity, - rules: testCase.rules ?? [], - }; - } finally { - fs.rmSync(fixture.root, { force: true, recursive: true }); - if (fixture.externalRoot) fs.rmSync(fixture.externalRoot, { force: true, recursive: true }); - } -} - -const results = cases.map(runCase); +const runCase = makeConsumerReferenceCaseRunner({ baseItem, schema }); +const results = [...cases.map(runCase), ...migrationReceiverCases.map(runMigrationReceiverCase)]; const failures = results .filter((result) => !result.ok) .map((result) => `missing_semantic:${result.name}:${result.expected}`); @@ -224,12 +238,26 @@ const coveredSchemaRules = new Set(results.flatMap((result) => result.rules)); const expectedSchemaRules = new Set(expectedSchemaRuleNames); const missingSchemaRules = [...expectedSchemaRules].filter((rule) => !coveredSchemaRules.has(rule)); failures.push(...missingSchemaRules.map((rule) => `missing_schema_rule:${rule}`)); +const profileHarness = spawnSync(process.execPath, [path.join(repositoryRoot, "scripts", "test-validate-reference-profiles.mjs"), "--json"], { + cwd: repositoryRoot, + encoding: "utf8", +}); +const profileReport = JSON.parse(profileHarness.stdout); +if (profileHarness.status !== 0 || profileReport.ok !== true) failures.push("missing_semantic:governed_local_reference_profiles"); +const cliHarness = spawnSync(process.execPath, [path.join(repositoryRoot, "scripts", "test-validate-consumer-reference-cli.mjs")], { + cwd: repositoryRoot, + encoding: "utf8", +}); +const cliReport = JSON.parse(cliHarness.stdout); +if (cliHarness.status !== 0 || cliReport.ok !== true) failures.push("missing_semantic:consumer_reference_cli_arguments"); const report = { + cliReport, failures, ok: failures.length === 0, + profileReport, results, schemaParity: { coveredRules: [...coveredSchemaRules].sort(), missingRules: missingSchemaRules }, }; console.log(JSON.stringify(report, null, 2)); -process.exit(report.ok ? 0 : 1); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/test-validate-domains.mjs b/scripts/test-validate-domains.mjs index dd2e300..7c83627 100644 --- a/scripts/test-validate-domains.mjs +++ b/scripts/test-validate-domains.mjs @@ -4,8 +4,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; -const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const validator = path.join(root, "scripts", "validate-domains.mjs"); const revision = "220e8607c90b17337d210125777b7b695f26c221"; const repository = "https://github.com/emilkowalski/skills"; @@ -39,15 +40,28 @@ function indexPage(title, links) { ].join("\n"); } -function leafPage({ title, domain, sourcePath, parent, next, lifecycle = "experimental", body = "" }) { - return [ +function leafPage({ title, domain, sourcePath, parent, next, lifecycle = "experimental", body = "", provenanceKind, forgedSourceFields = false, omitSourceFields = false }) { + const frontmatter = [ "---", "type: Domain Guide", `title: ${title}`, `description: Bounded ${title} guidance.`, `domain: ${domain}`, `lifecycle: ${lifecycle}`, - ...(sourcePath ? [`source_repository: ${repository}`, `source_path: ${sourcePath}`, `source_revision: ${revision}`] : []), + ]; + if (provenanceKind) frontmatter.push(`provenance_kind: ${provenanceKind}`); + if ((sourcePath && !provenanceKind && !omitSourceFields) || forgedSourceFields) { + frontmatter.push(`source_repository: ${repository}`, `source_path: ${sourcePath}`, `source_revision: ${revision}`); + } + const attribution = provenanceKind === "local" + ? ["- Local StyleGallery method; no upstream attribution."] + : sourcePath ? [ + `- Upstream inspiration: ${repository}/blob/${revision}/${sourcePath}`, + `- Snapshot: \`${revision}\``, + "- Reuse form: independent method rewrite.", + ] : ["- Reuse form: locally authored method."]; + return [ + ...frontmatter, "---", "", `# ${title}`, @@ -79,7 +93,7 @@ function leafPage({ title, domain, sourcePath, parent, next, lifecycle = "experi "", "## Source, License, And Attribution", "", - ...(sourcePath ? [`- Upstream inspiration: ${repository}/blob/${revision}/${sourcePath}`, `- Snapshot: \`${revision}\``, "- Reuse form: independent method rewrite."] : ["- Reuse form: locally authored method."]), + ...attribution, "", "## IA Navigation", "", @@ -89,6 +103,23 @@ function leafPage({ title, domain, sourcePath, parent, next, lifecycle = "experi ].join("\n"); } +const localLeafPath = "design-engineering/consumer-migration-readiness.md"; +const localLeafRegistry = (canonicalDomains) => { + const registry = structuredClone(canonicalDomains); + const designEngineering = registry.find((domain) => domain.slug === "design-engineering"); + if (!designEngineering.leaves.some((leaf) => leaf.path === localLeafPath)) designEngineering.leaves.push({ path: localLeafPath, provenance: "local" }); + return registry; +}; + +const localLeaf = leafPage({ + title: "Consumer Migration Readiness", + domain: "design-engineering", + parent: "index.md", + next: "../platform-guides/index.md", + provenanceKind: "local", + sourcePath: "unused/local-source.md", +}); + const baseFiles = { "README.md": `# StyleGallery\n\nConsumer Reference is shared non-domain infrastructure ${readmeConsumerReferenceBoundary}.\n\n- [Layout](layout/index.md)\n- [Motion](motion/index.md)\n- [Design Engineering](design-engineering/index.md)\n- [Game UI](game-ui/index.md)\n- [Platform Guides](platform-guides/index.md)\n`, "index.md": "# StyleGallery\n\n- [Layout](layout/index.md)\n- [Motion](motion/index.md)\n- [Design Engineering](design-engineering/index.md)\n- [Game UI](game-ui/index.md)\n- [Platform Guides](platform-guides/index.md)\n", @@ -111,7 +142,7 @@ const baseFiles = { "| --- | --- | --- |", "| Layout | `layout/index.md` | Existing Layout corpus. |", "| Motion | `motion/index.md` | `motion/vocabulary.md`, `motion/review-workflow.md`, `motion/practice-reference.md` |", - "| Design Engineering | `design-engineering/index.md` | `design-engineering/interface-craft.md` |", + "| Design Engineering | `design-engineering/index.md` | `design-engineering/interface-craft.md`, `design-engineering/consumer-migration-readiness.md`, `design-engineering/reference-profiles/index.md`, `design-engineering/reference-profiles/governed-local/index.md`, `design-engineering/reference-profiles/external-adaptation/index.md` |", "| Game UI | `game-ui/index.md` | `game-ui/classification.md`, `game-ui/screen-hierarchy.md`, `game-ui/reference-record.md`, `game-ui/unity/architecture.md`, `game-ui/unity/ui-systems.md`, `game-ui/unity/cli-loop.md`, `game-ui/unity/repository-map.md`, `game-ui/unity/org-wiki.md` |", "| Platform Guides | `platform-guides/index.md` | `platform-guides/apple-interaction.md` |", "", @@ -121,14 +152,27 @@ const baseFiles = { "", "[Consumer Reference](consumer-reference/index.md) is shared infrastructure outside the five-domain contract and cannot add a sixth domain row.", "", + "### Consumer Reference Promotion", + "", + "The gateway applies only to consumer-local → shared-experimental invariant eligibility.", + "Editorial and terminal are related examples in one fixture set.", + "Shared stable has no numeric adoption threshold.", + "Normative correctness may waive adoption count only.", + "A stable contract is never silently relabeled experimental.", + "Promotion records are JSON-only and examples have zero adopter attestations.", + "", ].join("\n"), "layout/index.md": indexPage("Layout", [["Catalog", "../CATALOG.md"]]), "motion/index.md": indexPage("Motion", [["Motion Vocabulary", "vocabulary.md"], ["Motion Review Workflow", "review-workflow.md"], ["Motion Practice Reference", "practice-reference.md"]]), "motion/vocabulary.md": leafPage({ title: "Motion Vocabulary", domain: "motion", sourcePath: "skills/animation-vocabulary/SKILL.md", parent: "index.md", next: "review-workflow.md" }), "motion/review-workflow.md": leafPage({ title: "Motion Review Workflow", domain: "motion", sourcePath: "skills/review-animations/SKILL.md", parent: "index.md", next: "practice-reference.md" }), "motion/practice-reference.md": leafPage({ title: "Motion Practice Reference", domain: "motion", sourcePath: "skills/review-animations/STANDARDS.md", parent: "index.md", next: "../design-engineering/index.md" }), - "design-engineering/index.md": indexPage("Design Engineering", [["Interface Craft", "interface-craft.md"]]), + "design-engineering/index.md": indexPage("Design Engineering", [["Interface Craft", "interface-craft.md"], ["Consumer Migration Readiness", "consumer-migration-readiness.md"], ["Reference Profiles", "reference-profiles/index.md"]]), "design-engineering/interface-craft.md": leafPage({ title: "Interface Craft", domain: "design-engineering", sourcePath: "skills/emil-design-eng/SKILL.md", parent: "index.md", next: "../platform-guides/index.md" }), + [localLeafPath]: localLeaf, + "design-engineering/reference-profiles/index.md": "# Reference Profiles\n\nDomain classification: design-engineering.\n\n- [Governed Local Profiles](governed-local/index.md)\n\nParent: [Design Engineering](../index.md).\nNext: [External adaptation](external-adaptation/index.md).\n", + "design-engineering/reference-profiles/governed-local/index.md": "# Governed Local Profiles\n\nParent: [Reference Profiles](../index.md).\nNext: [External adaptation](../external-adaptation/index.md).\n", + "design-engineering/reference-profiles/external-adaptation/index.md": "# External Adaptation\n\nSynthetic validator coverage only; no durable adopter record.\n\nParent: [Reference Profiles](../index.md).\nNext: [Platform Guides](../../../platform-guides/index.md).\n", "game-ui/index.md": indexPage("Game UI", [["Game UI Classification", "classification.md"], ["Game UI Screen Hierarchy", "screen-hierarchy.md"], ["Game UI Reference Record", "reference-record.md"], ["Unity UI Architecture", "unity/architecture.md"], ["Unity UI Systems", "unity/ui-systems.md"], ["Unity CLI Loop", "unity/cli-loop.md"], ["Unity Repository Map", "unity/repository-map.md"], ["Unity Organization Compressed Wiki", "unity/org-wiki.md"]]), "game-ui/classification.md": leafPage({ title: "Game UI Classification", domain: "game-ui", parent: "index.md", next: "screen-hierarchy.md" }), "game-ui/screen-hierarchy.md": leafPage({ title: "Game UI Screen Hierarchy", domain: "game-ui", parent: "index.md", next: "reference-record.md" }), @@ -156,6 +200,10 @@ const cases = [ { name: "manifest_extra_domain", mutate: ["DOMAINS.md", "| Platform Guides | [Platform Guides](platform-guides/index.md) | `experimental` |", "| Platform Guides | [Platform Guides](platform-guides/index.md) | `experimental` |\n| Other | [Other](other/index.md) | `experimental` |"], expect: "DOMAINS.md: missing canonical domain contract" }, { name: "consumer_reference_sixth_domain", mutate: ["DOMAINS.md", "| Platform Guides | [Platform Guides](platform-guides/index.md) | `experimental` |", "| Platform Guides | [Platform Guides](platform-guides/index.md) | `experimental` |\n| Consumer Reference | [Consumer Reference](consumer-reference/index.md) | `stable` |"], expect: "DOMAINS.md: missing canonical domain contract" }, { name: "consumer_reference_four_domain_contract", mutate: ["DOMAINS.md", "five-domain contract", "four-domain contract"], expect: "DOMAINS.md: missing canonical domain contract" }, + { name: "promotion_stable_by_count", mutate: ["DOMAINS.md", "Shared stable has no numeric adoption threshold.", "Shared stable uses a numeric adoption threshold."], expect: "DOMAINS.md: missing promotion boundary Shared stable has no numeric adoption threshold" }, + { name: "promotion_related_as_independent", mutate: ["DOMAINS.md", "Editorial and terminal are related examples in one fixture set.", "Editorial and terminal are independent consumers."], expect: "DOMAINS.md: missing promotion boundary Editorial and terminal are related examples in one fixture set" }, + { name: "promotion_yaml_route", mutate: ["DOMAINS.md", "Promotion records are JSON-only", "Promotion records may use YAML"], expect: "DOMAINS.md: missing promotion boundary Promotion records are JSON-only" }, + { name: "reference_profiles_fifth_domain", mutate: ["design-engineering/reference-profiles/index.md", "Domain classification: design-engineering.", "Domain classification: reference-profiles."], expect: "design-engineering/reference-profiles/index.md: reference profiles must remain in the Design Engineering domain" }, { name: "manifest_extra_leaf", mutate: ["DOMAINS.md", "`motion/vocabulary.md`,", "`motion/vocabulary.md`, `motion/ghost.md`,"], expect: "DOMAINS.md: missing canonical domain contract" }, { name: "manifest_wrong_lifecycle", mutate: ["DOMAINS.md", "| Motion | [Motion](motion/index.md) | `experimental` |", "| Motion | [Motion](motion/index.md) | `stable` |"], expect: "DOMAINS.md: missing canonical domain contract" }, { name: "missing_domain_index", omit: ["design-engineering/index.md"], expect: "design-engineering/index.md: missing file" }, @@ -166,6 +214,24 @@ const cases = [ { name: "missing_unity_repository_map_leaf", omit: ["game-ui/unity/repository-map.md"], expect: "game-ui/unity/repository-map.md: missing file" }, { name: "missing_unity_org_wiki_leaf", omit: ["game-ui/unity/org-wiki.md"], expect: "game-ui/unity/org-wiki.md: missing file" }, { name: "undeclared_domain_leaf", add: ["motion/rogue.md", "# Rogue\n"], expect: "motion/rogue.md: undeclared governed domain document" }, + { + name: "local_leaf_success", + registry: localLeafRegistry, + expect: null, + }, + { + name: "local_leaf_missing_provenance", + registry: localLeafRegistry, + mutate: [localLeafPath, "provenance_kind: local\n", ""], + expect: "design-engineering/consumer-migration-readiness.md: missing provenance_kind", + }, + { + name: "local_leaf_forged_source", + registry: localLeafRegistry, + add: [localLeafPath, leafPage({ title: "Consumer Migration Readiness", domain: "design-engineering", parent: "index.md", next: "../platform-guides/index.md", sourcePath: "unused/local-source.md", provenanceKind: "local", forgedSourceFields: true })], + expect: "design-engineering/consumer-migration-readiness.md: local leaf must not declare source_repository", + }, + { name: "undeclared_local_leaf", add: ["design-engineering/rogue-local.md", localLeaf.replace("title: Consumer Migration Readiness", "title: Rogue Local Method")], expect: "design-engineering/rogue-local.md: undeclared governed domain document" }, { name: "undeclared_nested_game_ui_leaf", add: ["game-ui/unity/rogue.md", "# Rogue\n"], expect: "game-ui/unity/rogue.md: undeclared governed domain document" }, { name: "unknown_domain", mutate: ["motion/vocabulary.md", "domain: motion", "domain: unknown"], expect: "motion/vocabulary.md: unknown domain unknown" }, { name: "wrong_domain", mutate: ["motion/vocabulary.md", "domain: motion", "domain: platform-guides"], expect: "motion/vocabulary.md: domain platform-guides does not match motion" }, @@ -215,13 +281,11 @@ function writeFixture(testCase) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `stylegallery-domains-${testCase.name}-`)); const omitted = new Set(testCase.omit ?? []); const entries = { ...baseFiles }; - if (testCase.mutate) { - const [relative, before, after] = testCase.mutate; - entries[relative] = entries[relative].replace(before, after); - } + const mutations = testCase.mutations ?? (testCase.mutate ? [testCase.mutate] : []); + for (const [relative, before, after] of mutations) entries[relative] = entries[relative].replace(before, after); if (testCase.add) { - const [relative, content] = testCase.add; - entries[relative] = content; + const additions = Array.isArray(testCase.add[0]) ? testCase.add : [testCase.add]; + for (const [relative, content] of additions) entries[relative] = content; } for (const [relative, content] of Object.entries(entries)) { if (omitted.has(relative)) continue; @@ -232,21 +296,62 @@ function writeFixture(testCase) { return dir; } -function runCase(testCase) { +let validatorModulePromise; +async function loadValidatorModule() { + if (!validatorModulePromise) { + validatorModulePromise = (async () => { + const previousExitCode = process.exitCode; + const originalLog = console.log; + const originalError = console.error; + console.log = () => {}; + console.error = () => {}; + try { + return await import(pathToFileURL(validator).href); + } catch { + return null; + } finally { + console.log = originalLog; + console.error = originalError; + process.exitCode = previousExitCode; + } + })(); + } + return validatorModulePromise; +} + +async function runCase(testCase) { const dir = writeFixture(testCase); - const result = spawnSync(process.execPath, [validator, "--json"], { cwd: dir, encoding: "utf8" }); - fs.rmSync(dir, { force: true, recursive: true }); let output; - try { - output = JSON.parse(result.stdout); - } catch { - output = { ok: false, failures: ["validator unavailable or returned non-JSON output"] }; + if (testCase.registry) { + const module = await loadValidatorModule(); + if (module?.validateDomains && module.canonicalDomains) { + output = module.validateDomains({ root: dir, domains: testCase.registry(module.canonicalDomains) }); + } else { + output = { ok: false, failures: ["validator does not expose a registry injection function"] }; + } + } else { + const result = spawnSync(process.execPath, [validator, "--json"], { cwd: dir, encoding: "utf8" }); + try { + output = JSON.parse(result.stdout); + } catch { + output = { ok: false, failures: ["validator unavailable or returned non-JSON output"] }; + } } + fs.rmSync(dir, { force: true, recursive: true }); const passed = testCase.expect ? !output.ok && output.failures.includes(testCase.expect) : output.ok; return { name: testCase.name, ok: passed, expected: testCase.expect ?? "ok:true", actual: output }; } -const results = cases.map(runCase); +const caseIndex = process.argv.indexOf("--case"); +const requestedCase = caseIndex === -1 ? null : process.argv[caseIndex + 1]; +const selectedCases = requestedCase ? cases.filter((testCase) => testCase.name === requestedCase) : cases; +if (requestedCase && selectedCases.length === 0) { + console.error(`unknown case: ${requestedCase}`); + process.exitCode = 1; + process.exit(); +} + +const results = await Promise.all(selectedCases.map(runCase)); const report = { ok: results.every((result) => result.ok), results }; console.log(JSON.stringify(report, null, 2)); -process.exit(report.ok ? 0 : 1); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/test-validate-governance.mjs b/scripts/test-validate-governance.mjs index 2516fd0..567aefb 100644 --- a/scripts/test-validate-governance.mjs +++ b/scripts/test-validate-governance.mjs @@ -4,132 +4,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { files, generatedWarning, sentinelProvenanceClauses } from "./governance-test-fixture.mjs"; +import { governanceMatrixCases } from "./governance-matrix-negative-cases.mjs"; +import { componentStateWorkflowCases } from "./component-state-workflow-negative-cases.mjs"; +import { workflowSafetyCases } from "./governance-workflow-negative-cases.mjs"; -const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const validator = path.join(root, "scripts", "validate-governance.mjs"); -const generatedWarning = ""; - -const files = { - "AGENTS.md": "# Agent Instructions\n\nSee [Governance, Lifecycle, And Docs-As-Code](GOVERNANCE.md) and [StyleGallery Domains](DOMAINS.md).\n", - "README.md": "# StyleGallery\n\n- [Governance, Lifecycle, And Docs-As-Code](GOVERNANCE.md)\n- [StyleGallery Domains](DOMAINS.md)\n", - "index.md": "# StyleGallery\n\n- [Governance, lifecycle, and docs-as-code](GOVERNANCE.md)\n- [StyleGallery Domains](DOMAINS.md)\n", - "DOMAINS.md": "# StyleGallery Domains\n", - "CATALOG.md": `# Layout Pattern Catalog\n\n${generatedWarning}\n`, - "patterns/index.md": `# Pattern Categories\n\n${generatedWarning}\n`, - "patterns/stacking/index.md": `# Stacking\n\n${generatedWarning}\n`, - "patterns/stacking/stack.md": [ - "---", - "lifecycle: generated", - "generated_from: scripts/generate-patterns.mjs, scripts/pattern-data.mjs", - "---", - "", - "# stack", - "", - generatedWarning, - "", - ].join("\n"), - "quality/evidence/executable-evidence.md": [ - "| Claim | Validator or test | CI command | Positive evidence | Negative evidence | Evidence boundary |", - "| Governance, lifecycle, generated-file, ownership, and stale-content policy remain discoverable and CI-enforced. | `scripts/validate-governance.mjs` and `scripts/test-validate-governance.mjs` | `node scripts/validate-governance.mjs --json`; `node scripts/test-validate-governance.mjs --json` | `GOVERNANCE.md`, `.github/CODEOWNERS`, generated warnings, generated metadata, root links, lifecycle states, stale-audit decision, and CI wiring are present. | Missing governance file, generated warning, generated metadata, CODEOWNERS coverage, or stale policy fixtures must fail. | Proves governance policy is present and linked, not that CODEOWNERS users have verified repository write access. |", - "| Domain topology, metadata, provenance, scope boundaries, and root routes remain enforced. | `scripts/validate-domains.mjs` and `scripts/test-validate-domains.mjs` | `node scripts/validate-domains.mjs --json`; `node scripts/test-validate-domains.mjs` | Five governed domains and their declared leaves are reachable and attributed. | Domain metadata, immutable provenance, scope boundaries, and root-route fixtures must fail. | A full SHA proves content identity syntax, not publisher authenticity or local quality. |", - "Consumer-reference handoffs, schema/runtime parity, and containment remain enforced; repository handoff omissions must fail.", - "", - ].join("\n"), - ".github/CODEOWNERS": [ - "* @changeroa", - "/GOVERNANCE.md @changeroa", - "/README.md @changeroa", - "/index.md @changeroa", - "/AGENTS.md @changeroa", - "/DOMAINS.md @changeroa", - "/GUIDE.md @changeroa", - "/guides/ @changeroa", - "/recipes/ @changeroa", - "/quality/ @changeroa", - "/layout/ @changeroa", - "/motion/ @changeroa", - "/design-engineering/ @changeroa", - "/platform-guides/ @changeroa", - "/consumer-reference/ @changeroa", - "/scripts/pattern-data.mjs @changeroa", - "/scripts/generate-patterns.mjs @changeroa", - "/patterns/ @changeroa", - "/CATALOG.md @changeroa", - "/.github/workflows/validate.yml @changeroa", - "/.github/ @changeroa", - "/scripts/ @changeroa", - "/scripts/validate-*.mjs @changeroa", - "/scripts/test-validate-*.mjs @changeroa", - "", - ].join("\n"), - ".github/workflows/validate.yml": [ - "node -c scripts/validate-governance.mjs", - "node -c scripts/test-validate-governance.mjs", - "node scripts/validate-governance.mjs --json", - "node scripts/test-validate-governance.mjs --json", - "node -c scripts/validate-domains.mjs", - "node -c scripts/test-validate-domains.mjs", - "node scripts/validate-domains.mjs --json", - "node scripts/test-validate-domains.mjs", - "node scripts/validate-webpage-workflow.mjs --json", - "node scripts/test-validate-webpage-workflow.mjs --json", - "node -c scripts/validate-consumer-reference.mjs", - "node -c scripts/test-validate-consumer-reference.mjs", - "node scripts/validate-consumer-reference.mjs --json", - "node scripts/test-validate-consumer-reference.mjs --json", - "permissions:", - "contents: read", - "", - ].join("\n"), - "GOVERNANCE.md": [ - "---", - "scheduled_stale_audit: deferred", - "---", - "", - "# Governance, Lifecycle, And Docs-As-Code", - "", - "| Doc family | Source of truth | Generator | Generated artifacts | Lifecycle state | Stale trigger | Validator | Review owner |", - "| Root repository guide | `README.md` | Manual | None | `stable` | Source-of-truth route changes, broken root links, or ownership changes. | `scripts/validate-okf.mjs` | Repository governance owner |", - "| OKF bundle map | `index.md` | Manual | None | `scripts/validate-okf.mjs` | Repository governance owner |", - "| Agent editing rules | `AGENTS.md` | Manual | None | `scripts/validate-links.mjs` | Repository governance owner |", - "| Planning guides | `GUIDE.md`, `guides/*.md` | Manual | None | `scripts/validate-okf.mjs` | Planning-doc owner |", - "| Layout recipes | `recipes/*.md` | Manual | None | `scripts/validate-okf.mjs` | Recipe owner |", - "| Quality gates and evidence | `quality/**/*.md` | Manual | None | `scripts/validate-okf.mjs` | Quality owner |", - "| Consumer reference contract | `consumer-reference/contract.md` | Manual | None | `stable` | Receiver changes. | `scripts/validate-consumer-reference.mjs` | Validation owner |", - "| Domain manifest and scope decision | `DOMAINS.md`, `quality/claim-records/stylegallery-multidomain-scope.md` | Manual | None | `stable` | Domain membership, repository-scope, or provenance-policy changes. | `scripts/validate-domains.mjs`, `scripts/validate-governance.mjs` | Repository governance owner |", - "| Layout domain hub | `layout/index.md` | Manual | None | `stable` | Layout route or ownership changes. | `scripts/validate-domains.mjs`, `scripts/validate-ia.mjs` | Pattern-data owner |", - "| Motion domain guidance | `motion/*.md` | Manual | None | `experimental` | Upstream revision, evidence boundary, or guidance changes. | `scripts/validate-domains.mjs` | Motion domain owner |", - "| Design Engineering domain guidance | `design-engineering/*.md` | Manual | None | `experimental` | Upstream revision, evidence boundary, or guidance changes. | `scripts/validate-domains.mjs` | Design Engineering domain owner |", - "| Game UI domain guidance | `game-ui/**/*.md` | Manual | None | `experimental` | Classification, hierarchy, engine implementation, evidence boundary, or route changes. | `scripts/validate-domains.mjs` | Game UI domain owner |", - "| Platform Guides domain guidance | `platform-guides/*.md` | Manual | None | `experimental` | Platform version, upstream revision, evidence boundary, or guidance changes. | `scripts/validate-domains.mjs` | Platform Guides domain owner |", - "| Pattern data and examples | `scripts/pattern-data.mjs` | Manual data source | `patterns/**/*.md` | `generated` output from `stable` source | Source-lineage URL changes, generated drift, category changes, or pattern count changes. | `scripts/validate-patterns.mjs`, `scripts/validate-catalog.mjs`, `scripts/validate-governance.mjs` | Pattern-data owner |", - "| Pattern generator | `scripts/generate-patterns.mjs` | Manual code source | `patterns/**/*.md` | `stable` generator, `generated` output | Generated structure changes, generated-warning changes, or generated metadata changes. | `node -c scripts/generate-patterns.mjs` | Pattern-data owner |", - "| Validation scripts | `scripts/validate-*.mjs`, `scripts/test-validate-*.mjs` | Manual code source | CI validation output | `node -c` | Validation owner |", - "| CI workflow | `.github/workflows/validate.yml` | Manual | GitHub Actions run | GitHub Actions | Repository governance owner |", - "", - "## Generated Artifact Policy", - "Do not hand-edit generated artifacts.", - "`scripts/pattern-data.mjs`", - "`scripts/generate-patterns.mjs`", - "", - "## Lifecycle States", - "`draft` `stable` `deprecated` `experimental` `generated`", - "", - "## Review Ownership", - "CODEOWNERS", - "owner.enforcement: \"placeholder\"", - "review_independence: \"single_account\"", - "", - "## Staleness Control", - "Decision: no scheduled stale-content workflow yet.", - "Audit trigger:", - "node scripts/validate-links.mjs --json", - "", - ].join("\n"), -}; +const adapterHarness = path.join(root, "scripts", "test-reference-adapters.mjs"); +const adapterPipeParser = 'let output = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { output += chunk; }); process.stdin.on("end", () => { const report = JSON.parse(output); const complete = report.ok === true && report.failures.length === 0 && report.results.length === 42; process.stdout.write(complete ? "42\\n" : "incomplete\\n"); process.exitCode = complete ? 0 : 1; });'; const cases = [ { name: "missing_governance", omit: ["GOVERNANCE.md"], expect: "GOVERNANCE.md: missing file" }, + { name: "broad_profile_sources_claim_no_generated_artifacts", mutate: { "GOVERNANCE.md": files["GOVERNANCE.md"].replace(/`design-engineering\/reference-profiles\/governed-local\/editorial\/profile\.json`[^|]+/, "`design-engineering/reference-profiles/governed-local/**`") }, expect: "GOVERNANCE.md: governed local reference profile sources must be the six explicit canonical profile files" }, { name: "missing_matrix_stale_trigger", mutate: { "GOVERNANCE.md": files["GOVERNANCE.md"].replace("Generated structure changes, generated-warning changes, or generated metadata changes.", "Generated structure changes.") }, expect: "GOVERNANCE.md: missing Generated structure changes, generated-warning changes, or generated metadata changes." }, { name: "missing_game_ui_domain_family", mutate: { "GOVERNANCE.md": files["GOVERNANCE.md"].replace(/^\| Game UI domain guidance .*\n/m, "") }, expect: "GOVERNANCE.md: missing Game UI domain guidance" }, { name: "missing_evidence_fixture_coverage", mutate: { "quality/evidence/executable-evidence.md": "Missing governance file or generated warning fixtures must fail." }, expect: "quality/evidence/executable-evidence.md: missing Missing governance file, generated warning, generated metadata, CODEOWNERS coverage, or stale policy fixtures must fail." }, @@ -176,25 +64,181 @@ const cases = [ expect: ".github/workflows/validate.yml: missing permissions:", }, { name: "missing_stale_policy", mutate: { "GOVERNANCE.md": files["GOVERNANCE.md"].replace("scheduled_stale_audit: deferred\n", "") }, expect: "GOVERNANCE.md: missing scheduled_stale_audit: deferred" }, + { name: "missing_evidence_audit_policy", mutate: { "GOVERNANCE.md": files["GOVERNANCE.md"].replace("scheduled_evidence_audit: active_advisory\n", "") }, expect: "GOVERNANCE.md: missing scheduled_evidence_audit: active_advisory" }, { name: "paraphrased_stale_policy", mutate: { "GOVERNANCE.md": files["GOVERNANCE.md"].replace( - "Decision: no scheduled stale-content workflow yet.", - "Decision: a scheduled stale-content workflow is not needed yet.", + "Decision: run a weekly advisory audit only for evidence records that already declare `expires_at` or `review_by`.", + "Decision: inspect old evidence from time to time.", ), }, - expectWarning: "GOVERNANCE.md: recommended wording missing Decision: no scheduled stale-content workflow yet.", + expect: "GOVERNANCE.md: missing Decision: run a weekly advisory audit only for evidence records that already declare `expires_at` or `review_by`.", + }, + { name: "missing_no_universal_age_boundary", mutate: { "GOVERNANCE.md": files["GOVERNANCE.md"].replace("No repository-wide maximum age or inferred time-to-live applies.", "Evidence gets old after 30 days.") }, expect: "GOVERNANCE.md: missing No repository-wide maximum age or inferred time-to-live applies." }, + { name: "missing_freshness_workflow", omit: [".github/workflows/evidence-freshness.yml"], expect: ".github/workflows/evidence-freshness.yml: missing file" }, + { name: "freshness_schedule_drift", mutate: { ".github/workflows/evidence-freshness.yml": files[".github/workflows/evidence-freshness.yml"].replace("cron: '0 5 * * 1'", "cron: '0 5 * * 2'") }, expect: ".github/workflows/evidence-freshness.yml: missing Monday 05:00 UTC schedule" }, + { name: "freshness_mode_became_blocking", mutate: { ".github/workflows/evidence-freshness.yml": files[".github/workflows/evidence-freshness.yml"].replace("--mode advisory", "--mode blocking") }, expect: ".github/workflows/evidence-freshness.yml: missing --mode advisory" }, + { name: "freshness_blanket_continue", mutate: { ".github/workflows/evidence-freshness.yml": `${files[".github/workflows/evidence-freshness.yml"]}\ncontinue-on-error: true\n` }, expect: ".github/workflows/evidence-freshness.yml: continue-on-error is forbidden because malformed records and tool failures must block" }, + { name: "consumer_browser_became_nonblocking", mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" consumer-conformance:\n", " consumer-conformance:\n continue-on-error: true\n") }, expect: ".github/workflows/validate.yml: consumer-conformance must be blocking" }, + { + name: "consumer_browser_command_commented_out", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - run: |\n # npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line\n true") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_command_relocated", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - run: true").replace(" chromium-sentinel:\n", " chromium-sentinel:\n steps:\n - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line\n") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_command_hidden_in_env", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - name: Hidden browser command\n env:\n run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line\n run: true") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", }, + { + name: "consumer_browser_command_hidden_in_inline_comment", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - run: true # npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_command_in_disabled_step", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - name: Disabled browser command\n if: false\n run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_command_hidden_in_heredoc", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - run: |\n cat <<'PROBE'\n npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line\n PROBE\n true") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_command_hidden_in_numeric_heredoc", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - run: |\n cat <<'123'\n npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line\n 123\n true") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_command_echo_only", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - run: echo npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_command_shell_builtin_echo", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - run: command echo npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_command_short_circuited", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - run: false && npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_command_failure_ignored", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line || true") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_command_split_failure_ignored", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - run: |\n npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line \\\n || true") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_command_folded_failure_ignored", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - run: >-\n npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line\n || true") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_command_compound_false_step", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - if: ${{ false && true }}\n run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line") }, + expect: ".github/workflows/validate.yml: consumer-conformance: required command steps must be unconditional", + }, + { + name: "consumer_browser_job_compound_false", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" consumer-conformance:\n", " consumer-conformance:\n if: ${{ false && true }}\n") }, + expect: ".github/workflows/validate.yml: consumer-conformance job must be unconditional", + }, + { + name: "consumer_browser_command_quoted_disabled_step", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", " - name: Disabled browser command\n \"if\": false # disabled\n run: npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line") }, + expect: ".github/workflows/validate.yml: consumer-conformance: missing npx playwright test tests/consumer-conformance.spec.mjs --project=chromium --reporter=line", + }, + { + name: "consumer_browser_job_disabled", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" consumer-conformance:\n", " consumer-conformance:\n if: false\n") }, + expect: ".github/workflows/validate.yml: consumer-conformance job must be enabled", + }, + { + name: "page_lifecycle_command_commented_out", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" - run: node scripts/finalize-page-evidence.mjs --json", " - run: |\n # node scripts/finalize-page-evidence.mjs --json\n true") }, + expect: ".github/workflows/validate.yml: consumer-page-evidence: missing node scripts/finalize-page-evidence.mjs", + }, + { name: "page_capture_became_blocking", mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace(" continue-on-error: true\n env:\n CONSUMER_CONFORMANCE_CAPTURE_DIR", " env:\n CONSUMER_CONFORMANCE_CAPTURE_DIR") }, expect: ".github/workflows/validate.yml: consumer-page-evidence must be nonblocking" }, { name: "paraphrased_governance_link_label", mutate: { "README.md": files["README.md"].replace("[Governance, Lifecycle, And Docs-As-Code]", "[Governance reference]") }, expectWarning: "README.md: recommended link label missing [Governance, Lifecycle, And Docs-As-Code](GOVERNANCE.md)" }, { name: "missing_motion_codeowner", mutate: { ".github/CODEOWNERS": files[".github/CODEOWNERS"].replace("/motion/ @changeroa\n", "") }, expect: ".github/CODEOWNERS: missing /motion/ @changeroa" }, + { name: "missing_game_ui_codeowner", mutate: { ".github/CODEOWNERS": files[".github/CODEOWNERS"].replace("/game-ui/ @changeroa\n", "") }, expect: ".github/CODEOWNERS: missing /game-ui/ @changeroa" }, { name: "missing_consumer_reference_codeowner", mutate: { ".github/CODEOWNERS": files[".github/CODEOWNERS"].replace("/consumer-reference/ @changeroa\n", "") }, expect: ".github/CODEOWNERS: missing /consumer-reference/ @changeroa" }, + { name: "missing_baseline_codeowner", mutate: { ".github/CODEOWNERS": files[".github/CODEOWNERS"].replace("/consumer-reference/baselines/ @changeroa\n", "") }, expect: ".github/CODEOWNERS: missing /consumer-reference/baselines/ @changeroa" }, + { name: "missing_promotion_codeowner", mutate: { ".github/CODEOWNERS": files[".github/CODEOWNERS"].replace("/consumer-reference/policies/ @changeroa\n", "") }, expect: ".github/CODEOWNERS: missing /consumer-reference/policies/ @changeroa" }, + { name: "missing_promotion_ci_wiring", mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace("node scripts/test-validate-promotion-rfc.mjs --json\n", "") }, expect: ".github/workflows/validate.yml: missing node scripts/test-validate-promotion-rfc.mjs --json" }, + { name: "missing_stable_count_boundary", mutate: { "GOVERNANCE.md": files["GOVERNANCE.md"].replace("Shared stable has no numeric adoption threshold.", "Stable uses an adoption threshold.") }, expect: "GOVERNANCE.md: missing Shared stable has no numeric adoption threshold" }, + { name: "fake_promotion_policy_claim", mutate: { "consumer-reference/policies/shared-experimental.json": files["consumer-reference/policies/shared-experimental.json"].replace('"promotion_occurred": false', '"promotion_occurred": true') }, expect: 'consumer-reference/policies/shared-experimental.json: missing "promotion_occurred": false' }, { name: "missing_domain_family", mutate: { "GOVERNANCE.md": files["GOVERNANCE.md"].replace("| Motion domain guidance |", "| Motion reference notes |") }, expect: "GOVERNANCE.md: missing Motion domain guidance" }, { name: "missing_domain_ci_wiring", mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace("node scripts/validate-domains.mjs --json\n", "") }, expect: ".github/workflows/validate.yml: missing node scripts/validate-domains.mjs --json" }, { name: "missing_domain_evidence_coverage", mutate: { "quality/evidence/executable-evidence.md": files["quality/evidence/executable-evidence.md"].replace("Domain metadata, immutable provenance, scope boundaries, and root-route fixtures must fail.", "Domain fixtures must fail.") }, expect: "quality/evidence/executable-evidence.md: missing Domain metadata, immutable provenance, scope boundaries, and root-route fixtures must fail." }, + { name: "missing_sentinel_ci_wiring", mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replaceAll("node scripts/test-consumer-reference-sentinel.mjs", "") }, expect: ".github/workflows/validate.yml: missing node scripts/test-consumer-reference-sentinel.mjs" }, + { name: "missing_component_source_contract_ci", mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace("node scripts/test-component-state-source-contract.mjs", "") }, expect: ".github/workflows/validate.yml: missing node scripts/test-component-state-source-contract.mjs" }, + { name: "missing_visual_schema_ci", mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace("consumer-reference/schema/visual-evidence.schema.json", "") }, expect: ".github/workflows/validate.yml: missing consumer-reference/schema/visual-evidence.schema.json" }, + ...componentStateWorkflowCases(files[".github/workflows/validate.yml"]), + { name: "missing_sentinel_evidence_coverage", mutate: { "quality/evidence/executable-evidence.md": files["quality/evidence/executable-evidence.md"].replace("The proposed Chromium sentinel preserves canonical card-grid geometry and truth-derived calibration evidence.", "Chromium evidence exists.") }, expect: "quality/evidence/executable-evidence.md: missing The proposed Chromium sentinel preserves canonical card-grid geometry and truth-derived calibration evidence." }, + { name: "missing_component_state_evidence_coverage", mutate: { "quality/evidence/executable-evidence.md": files["quality/evidence/executable-evidence.md"].replace("Governed-local button states retain source-bound visual, DOM, and accessibility-tree evidence across both example profiles.", "Component evidence exists.") }, expect: "quality/evidence/executable-evidence.md: missing Governed-local button states retain source-bound visual, DOM, and accessibility-tree evidence across both example profiles." }, + { + name: "missing_scoped_checkout_sha_trust", + mutate: { + ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace( + "checkout_sha=\"$(git -c safe.directory=\"$GITHUB_WORKSPACE\" rev-parse HEAD)\"", + "checkout_sha=\"$(git rev-parse HEAD)\"", + ), + }, + expect: ".github/workflows/validate.yml: missing checkout_sha=\"$(git -c safe.directory=\"$GITHUB_WORKSPACE\" rev-parse HEAD)\"", + }, + { + name: "reject_broad_checkout_sha_trust", + mutate: { + ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace( + "checkout_sha=\"$(git -c safe.directory=\"$GITHUB_WORKSPACE\" rev-parse HEAD)\"", + "checkout_sha=\"$(git -c safe.directory='*' rev-parse HEAD)\"", + ), + }, + expect: ".github/workflows/validate.yml: broad Git safe.directory wildcard is forbidden", + }, + { + name: "missing_canonical_repository_wiring", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace("--repository \"changeroa/StyleGallery\" \\", "--repository \"$GITHUB_REPOSITORY\" \\") }, + expect: ".github/workflows/validate.yml: missing --repository \"changeroa/StyleGallery\" \\", + }, + { + name: "missing_execution_repository_wiring", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace("--execution-repository \"$GITHUB_REPOSITORY\" \\\n", "") }, + expect: ".github/workflows/validate.yml: missing --execution-repository \"$GITHUB_REPOSITORY\" \\", + }, + { + name: "workflow_metadata_repository_mismatch", + mutate: { ".github/workflows/validate.yml": files[".github/workflows/validate.yml"].replace("\"changeroa/StyleGallery\" \"$GITHUB_REPOSITORY\" \"$GITHUB_RUN_ID\"", "\"$GITHUB_REPOSITORY\" \"changeroa/StyleGallery\" \"$GITHUB_RUN_ID\"") }, + expect: ".github/workflows/validate.yml: missing \"changeroa/StyleGallery\" \"$GITHUB_REPOSITORY\" \"$GITHUB_RUN_ID\"", + }, + ...governanceMatrixCases(files["GOVERNANCE.md"]), + ...workflowSafetyCases(files[".github/workflows/validate.yml"]), { name: "success_path", expect: null }, ]; +for (const [pathIndex, relative] of ["consumer-reference/contract.md", "quality/evidence/executable-evidence.md", "GOVERNANCE.md"].entries()) { + for (const [clauseIndex, clause] of sentinelProvenanceClauses.entries()) { + cases.splice(cases.length - 1, 0, + { name: `provenance_clause_deleted_${pathIndex}_${clauseIndex}`, mutate: { [relative]: files[relative].replace(clause, "") }, expect: `${relative}: missing ${clause}` }, + { name: `provenance_clause_misworded_${pathIndex}_${clauseIndex}`, mutate: { [relative]: files[relative].replace(clause, `Misworded ${clause.slice(1)}`) }, expect: `${relative}: missing ${clause}` }, + ); + } +} + function writeFixture(testCase) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `layout-gallery-governance-${testCase.name}-`)); const omitted = new Set(testCase.omit ?? []); @@ -230,10 +274,19 @@ function runCase(testCase) { } const results = cases.map(runCase); +const adapterPipeCheck = spawnSync("sh", ["-c", '"$NODE_BIN" "$ADAPTER_HARNESS" --json | "$NODE_BIN" -e "$ADAPTER_PIPE_PARSER"'], { + cwd: root, encoding: "utf8", env: { ...process.env, ADAPTER_HARNESS: adapterHarness, ADAPTER_PIPE_PARSER: adapterPipeParser, NODE_BIN: process.execPath }, +}); +results.push({ + actual: { status: adapterPipeCheck.status, stdout: adapterPipeCheck.stdout.trim() }, + expected: "complete 42-result JSON report through a pipe", + name: "adapter_json_pipe_complete", + ok: adapterPipeCheck.status === 0 && adapterPipeCheck.stdout.trim() === "42", +}); const report = { ok: results.every((result) => result.ok), results, }; console.log(JSON.stringify(report, null, 2)); -process.exit(report.ok ? 0 : 1); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/test-validate-ia.mjs b/scripts/test-validate-ia.mjs index 342bd3c..f9c6449 100644 --- a/scripts/test-validate-ia.mjs +++ b/scripts/test-validate-ia.mjs @@ -4,9 +4,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; -const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const validator = path.join(root, "scripts", "validate-ia.mjs"); +const governedProfileRoutes = [ + ["Editorial profile", "editorial/profile.json"], + ["Editorial state matrix", "editorial/generated/state-matrix.md"], + ["Editorial keyboard matrix", "editorial/generated/keyboard-matrix.md"], + ["Editorial evidence coverage", "editorial/generated/evidence-coverage.md"], + ["Terminal profile", "terminal/profile.json"], + ["Terminal state matrix", "terminal/generated/state-matrix.md"], + ["Terminal keyboard matrix", "terminal/generated/keyboard-matrix.md"], + ["Terminal evidence coverage", "terminal/generated/evidence-coverage.md"], +]; const baseFiles = { "README.md": [ @@ -24,6 +35,7 @@ const baseFiles = { "| [Design Engineering](design-engineering/index.md) | Design Engineering domain |", "| [Platform Guides](platform-guides/index.md) | Platform Guides domain |", "| [Consumer Reference](consumer-reference/index.md) | Shared infrastructure contract |", + "| [Consumer Migration Readiness](design-engineering/consumer-migration-readiness.md) | Migration method |", "", "## Task Routes", "", @@ -57,6 +69,7 @@ const baseFiles = { "- [Design Engineering](design-engineering/index.md)", "- [Platform Guides](platform-guides/index.md)", "- [Consumer reference](consumer-reference/index.md)", + "- [Consumer migration readiness](design-engineering/consumer-migration-readiness.md)", "", ].join("\n"), "GUIDE.md": "# Guide\n\nPrimary role: planning workflow.\n", @@ -66,18 +79,32 @@ const baseFiles = { "patterns/stacking/stack.md": leaf("Stack", "index.md", "../../recipes/index.md"), "recipes/index.md": "# Recipes\n", "recipes/homepage.md": leaf("Homepage", "index.md", "../quality/index.md"), - "quality/index.md": "# Quality\n\n## Tree-Test Findability QA\n", - "quality/gates/index.md": "# Gates\n", + "quality/index.md": "# Quality\n\n- [Consumer migration evidence gate](gates/consumer-migration-evidence.md)\n\n## Tree-Test Findability QA\n", + "quality/gates/index.md": "# Gates\n\n- [Consumer migration evidence gate](consumer-migration-evidence.md)\n", "quality/gates/layout.md": leaf("Layout", "index.md", "../evidence/index.md"), - "quality/evidence/index.md": "# Evidence\n", + "quality/gates/consumer-migration-evidence.md": leaf("Consumer Migration Evidence Gate", "index.md", "../evidence/consumer-migration.md"), + "quality/evidence/index.md": "# Evidence\n\n- [Consumer migration evidence](consumer-migration.md)\n", + "quality/evidence/consumer-migration.md": leaf("Consumer Migration Evidence", "index.md", "../../design-engineering/consumer-migration-readiness.md"), "layout/index.md": "# Layout\n\n- [Catalog](../CATALOG.md)\n", "motion/index.md": "# Motion\n\n- [Review](review.md)\n", "motion/review.md": leaf("Motion review", "index.md", "../design-engineering/index.md"), - "design-engineering/index.md": "# Design Engineering\n\n- [Craft](craft.md)\n", + "design-engineering/index.md": "# Design Engineering\n\n- [Craft](craft.md)\n- [Consumer Migration Readiness](consumer-migration-readiness.md)\n- [Reference Profiles](reference-profiles/index.md)\n", "design-engineering/craft.md": leaf("Craft", "index.md", "../platform-guides/index.md"), + "design-engineering/consumer-migration-readiness.md": leaf("Consumer Migration Readiness", "index.md", "../quality/gates/consumer-migration-evidence.md"), + "design-engineering/reference-profiles/index.md": "# Reference Profiles\n\n- [Governed Local Profiles](governed-local/index.md)\n- [External Adaptation](external-adaptation/index.md)\n\nParent: [Design Engineering](../index.md).\nNext: [Governed Local Profiles](governed-local/index.md).\n", + "design-engineering/reference-profiles/governed-local/index.md": [ + "# Governed Local Profiles", + "", + ...governedProfileRoutes.map(([label, target]) => `- [${label}](${target})`), + "", + "Parent: [Reference Profiles](../index.md).", + "Next: [External Adaptation](../external-adaptation/index.md).", + "", + ].join("\n"), + "design-engineering/reference-profiles/external-adaptation/index.md": "# External Adaptation\n\nParent: [Reference Profiles](../index.md).\nNext: [Platform Guides](../../../platform-guides/index.md).\n", "platform-guides/index.md": "# Platform Guides\n\n- [Apple](apple.md)\n", "platform-guides/apple.md": leaf("Apple", "index.md", "../layout/index.md"), - "consumer-reference/index.md": "# Consumer Reference\n\n- [Contract](contract.md)\n", + "consumer-reference/index.md": "# Consumer Reference\n\n- [Contract](contract.md)\n- [Consumer Migration Readiness](../design-engineering/consumer-migration-readiness.md)\n", "consumer-reference/contract.md": leaf("Consumer Reference Contract", "index.md", "../quality/index.md"), }; @@ -131,6 +158,27 @@ const cases = [ }, expect: "index.md: missing [Design Engineering](design-engineering/index.md)", }, + { + name: "missing_reference_profile_route", + mutate: { + "design-engineering/index.md": "# Design Engineering\n\n- [Craft](craft.md)\n", + }, + expect: "design-engineering/index.md: missing [Reference Profiles](reference-profiles/index.md)", + }, + ...governedProfileRoutes.map(([label, target]) => ({ + name: `missing_governed_route_${target.replace(/[^a-z]+/g, "_").replace(/^_|_$/g, "")}`, + mutate: { + "design-engineering/reference-profiles/governed-local/index.md": baseFiles["design-engineering/reference-profiles/governed-local/index.md"].replace(`- [${label}](${target})\n`, ""), + }, + expect: `design-engineering/reference-profiles/governed-local/index.md: missing [${label}](${target})`, + })), + { + name: "missing_external_adaptation_parent", + mutate: { + "design-engineering/reference-profiles/external-adaptation/index.md": "# External Adaptation\n\nNext: [Platform Guides](../../../platform-guides/index.md).\n", + }, + expect: "design-engineering/reference-profiles/external-adaptation/index.md: missing Parent navigation link", + }, { name: "missing_consumer_reference_route", mutate: { @@ -138,6 +186,20 @@ const cases = [ }, expect: "index.md: missing [Consumer reference](consumer-reference/index.md)", }, + { + name: "missing_consumer_migration_root_route", + mutate: { + "README.md": baseFiles["README.md"].replace("| [Consumer Migration Readiness](design-engineering/consumer-migration-readiness.md) | Migration method |\n", ""), + }, + expect: "README.md: missing [Consumer Migration Readiness](design-engineering/consumer-migration-readiness.md)", + }, + { + name: "missing_consumer_migration_gate_route", + mutate: { + "quality/index.md": "# Quality\n\n## Tree-Test Findability QA\n", + }, + expect: "quality/index.md: missing [Consumer migration evidence gate](gates/consumer-migration-evidence.md)", + }, { name: "missing_domain_leaf_parent", mutate: { @@ -197,4 +259,4 @@ const report = { }; console.log(JSON.stringify(report, null, 2)); -process.exit(report.ok ? 0 : 1); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/test-validate-okf.mjs b/scripts/test-validate-okf.mjs index d83a2d7..5471ea8 100644 --- a/scripts/test-validate-okf.mjs +++ b/scripts/test-validate-okf.mjs @@ -4,8 +4,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; -const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const validator = path.join(root, "scripts", "validate-okf.mjs"); const cases = [ @@ -69,4 +70,4 @@ const report = { }; console.log(JSON.stringify(report, null, 2)); -process.exit(report.ok ? 0 : 1); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/test-validate-page-evidence.mjs b/scripts/test-validate-page-evidence.mjs new file mode 100644 index 0000000..b4d258c --- /dev/null +++ b/scripts/test-validate-page-evidence.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { MAX_ARTIFACT_FILE_BYTES } from "./page-artifact-metadata.mjs"; +import { canonicalSourceManifest } from "./page-evidence-contract.mjs"; +import { + completeSession, + corruptPngCrc, + createConformance, + declareCapturedArtifact, + finalizeSession, + isolated, + pngWithTrailingBytes, + pngWithInvalidImageData, + pngWithTrailingImageData, + receiptFor, + reuseSetup, + rewriteCompletedCapture, + rewriteCompletedArtifact, + semanticEnvironment, + startSession, + started, + truncatedPng, + validateSession, + writeJson, + writeRunner, +} from "./page-evidence-fixture.mjs"; + +const requested = []; +let json = false; +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") json = true; + else if (argument === "--case") { + const value = process.argv[index + 1]; + if (value) { requested.push(value); index += 1; } + } +} + +function codes(child) { + return child?.report?.failures?.map((failure) => failure.code) ?? []; +} + +function outcome(name, expected, child, observe = {}) { + const actualCodes = codes(child); + const valid = expected === "ok:true"; + const ok = valid ? child?.status === 0 && child.report.ok === true : child?.status !== 0 && actualCodes.includes(expected); + return { actual: { codes: actualCodes, status: child?.status, ...observe }, expected, name, ok }; +} + +const cases = { + "complete-session": () => isolated((fixture) => { + const completed = completeSession(fixture); + return outcome("complete-session", "ok:true", completed.validate, { + finalized: completed.finalize?.report.ok === true, + scenarioCount: completed.validate?.report.scenarioCount, + started: completed.start?.report.ok === true, + }); + }), + "dirty-relevant-source": () => isolated((fixture) => { + const session = createConformance(fixture); + fs.appendFileSync(path.join(fixture.root, fixture.relevantSources[0]), "// dirty\n"); + return outcome("dirty-relevant-source", "page_evidence_source_dirty", startSession(fixture, session)); + }), + "untracked-relevant-source": () => isolated((fixture) => { + const session = createConformance(fixture); + const untracked = "src/untracked.js"; + fs.writeFileSync(path.join(fixture.root, untracked), "export const untracked = true;\n"); + const record = JSON.parse(fs.readFileSync(session.recordFile)); + record.consumer.relevant_sources.push(untracked); + const sourceFailures = []; + const source = canonicalSourceManifest(fixture.root, record.consumer.relevant_sources, sourceFailures); + record.scenarios[0].source_digest = source.sha256; + writeJson(session.recordFile, record); + return outcome("untracked-relevant-source", "page_evidence_source_dirty", startSession(fixture, session)); + }), + "source-drift-after-start": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session); + fs.appendFileSync(path.join(fixture.root, fixture.relevantSources[0]), "// drift\n"); + return outcome("source-drift-after-start", "page_evidence_source_drift", finalizeSession(fixture, session, runner)); + }), + "cross-revision": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session, { revision: "0".repeat(40), runRevision: "0".repeat(40) }); + return outcome("cross-revision", "page_evidence_revision_mismatch", finalizeSession(fixture, session, runner)); + }), + "cross-run": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session, { runId: "different-run" }); + return outcome("cross-run", "page_evidence_run_mismatch", finalizeSession(fixture, session, runner)); + }), + "cross-session-replay": () => isolated((fixture) => { + const target = started(fixture, { artifactName: "target" }); + const alternate = started(fixture, { artifactName: "alternate" }); + const targetRunner = writeRunner(fixture, target); + const alternateRunner = writeRunner(fixture, alternate); + fs.copyFileSync(alternateRunner.runnerFile, targetRunner.runnerFile); + fs.copyFileSync(path.join(alternate.artifactRoot, alternateRunner.artifactReference), path.join(target.artifactRoot, targetRunner.artifactReference)); + return outcome("cross-session-replay", "page_evidence_session_mismatch", finalizeSession(fixture, target, targetRunner)); + }), + "failed-runner": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session, { status: "failed" }); + return outcome("failed-runner", "page_evidence_runner_failed", finalizeSession(fixture, session, runner)); + }), + "finalize-runner-control-collision": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session); + const declared = declareCapturedArtifact(session, runner, runner.runnerReference); + return outcome("finalize-runner-control-collision", "page_evidence_artifact_control_collision", finalizeSession(fixture, session, declared)); + }), + "finalize-receipt-control-collision": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session); + const declared = declareCapturedArtifact(session, runner, "page-evidence-session.json"); + return outcome("finalize-receipt-control-collision", "page_evidence_artifact_control_collision", finalizeSession(fixture, session, declared)); + }), + "unmanifested-png": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session); + fs.writeFileSync(path.join(session.artifactRoot, "rogue.png"), "rogue"); + return outcome("unmanifested-png", "page_evidence_artifact_unmanifested", finalizeSession(fixture, session, runner)); + }), + "artifact-tree-depth-limit": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session); + fs.mkdirSync(path.join(session.artifactRoot, ...Array.from({ length: 18 }, (_, index) => `depth-${index}`)), { recursive: true }); + return outcome("artifact-tree-depth-limit", "page_evidence_artifact_limit", finalizeSession(fixture, session, runner)); + }), + "artifact-entry-limit": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session); + for (let index = 0; index < 4097; index += 1) fs.writeFileSync(path.join(session.artifactRoot, `entry-${index}.txt`), ""); + return outcome("artifact-entry-limit", "page_evidence_artifact_limit", finalizeSession(fixture, session, runner)); + }), + "artifact-file-byte-limit": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session); + fs.truncateSync(path.join(session.artifactRoot, runner.artifactReference), MAX_ARTIFACT_FILE_BYTES + 1); + return outcome("artifact-file-byte-limit", "page_evidence_artifact_limit", finalizeSession(fixture, session, runner)); + }), + "artifact-packet-byte-limit": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session); + for (let index = 0; index < 5; index += 1) { + const file = path.join(session.artifactRoot, `packet-${index}.bin`); + fs.closeSync(fs.openSync(file, "w")); + fs.truncateSync(file, 60 * 1024 * 1024); + } + return outcome("artifact-packet-byte-limit", "page_evidence_artifact_limit", finalizeSession(fixture, session, runner)); + }), + "missing-artifact": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session); + fs.rmSync(path.join(session.artifactRoot, runner.artifactReference)); + return outcome("missing-artifact", "page_evidence_artifact_missing", finalizeSession(fixture, session, runner)); + }), + "truncated-png": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session, { artifactBytes: truncatedPng() }); + return outcome("truncated-png", "page_evidence_png_invalid", finalizeSession(fixture, session, runner)); + }), + "corrupt-png-crc": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session, { artifactBytes: corruptPngCrc() }); + return outcome("corrupt-png-crc", "page_evidence_png_invalid", finalizeSession(fixture, session, runner)); + }), + "trailing-png-bytes": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session, { artifactBytes: pngWithTrailingBytes() }); + return outcome("trailing-png-bytes", "page_evidence_png_invalid", finalizeSession(fixture, session, runner)); + }), + "invalid-png-image-data-finalize": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session, { artifactBytes: pngWithInvalidImageData() }); + return outcome("invalid-png-image-data-finalize", "page_evidence_png_invalid", finalizeSession(fixture, session, runner)); + }), + "invalid-png-image-data-validate": () => isolated((fixture) => { + const completed = completeSession(fixture); + rewriteCompletedArtifact(completed.session, completed.runner, pngWithInvalidImageData()); + return outcome("invalid-png-image-data-validate", "page_evidence_png_invalid", validateSession(fixture, completed.session)); + }), + "trailing-png-image-data-finalize": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session, { artifactBytes: pngWithTrailingImageData() }); + return outcome("trailing-png-image-data-finalize", "page_evidence_png_invalid", finalizeSession(fixture, session, runner)); + }), + "hash-substitution": () => isolated((fixture) => { + const completed = completeSession(fixture); + fs.appendFileSync(path.join(completed.session.artifactRoot, completed.runner.artifactReference), "substitution"); + return outcome("hash-substitution", "page_evidence_artifact_integrity", validateSession(fixture, completed.session)); + }), + "validate-runner-control-collision": () => isolated((fixture) => { + const completed = completeSession(fixture); + rewriteCompletedCapture(completed.session, completed.runner, completed.runner.runnerReference); + return outcome("validate-runner-control-collision", "page_evidence_artifact_control_collision", validateSession(fixture, completed.session)); + }), + "validate-receipt-control-collision": () => isolated((fixture) => { + const completed = completeSession(fixture); + rewriteCompletedCapture(completed.session, completed.runner, "page-evidence-session.json"); + return outcome("validate-receipt-control-collision", "page_evidence_artifact_control_collision", validateSession(fixture, completed.session)); + }), + "validate-manifest-control-collision": () => isolated((fixture) => { + const completed = completeSession(fixture); + rewriteCompletedCapture(completed.session, completed.runner, "page-evidence-manifest.json"); + return outcome("validate-manifest-control-collision", "page_evidence_artifact_control_collision", validateSession(fixture, completed.session)); + }), + "artifact-symlink": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session); + const artifact = path.join(session.artifactRoot, runner.artifactReference); + fs.rmSync(artifact); + fs.symlinkSync("/etc/hosts", artifact); + return outcome("artifact-symlink", "page_evidence_artifact_symlink", finalizeSession(fixture, session, runner)); + }), + "artifact-redirect": () => isolated((fixture) => { + const completed = completeSession(fixture); + const realRoot = `${completed.session.artifactRoot}-real`; + fs.renameSync(completed.session.artifactRoot, realRoot); + fs.symlinkSync(realRoot, completed.session.artifactRoot); + return outcome("artifact-redirect", "page_evidence_artifact_redirect", validateSession(fixture, completed.session)); + }), + "parent-escape": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session, { artifactPath: "../outside.png" }); + return outcome("parent-escape", "page_evidence_artifact_escape", finalizeSession(fixture, session, runner)); + }), + "capture-outside-session": () => isolated((fixture) => { + const session = started(fixture); + const runner = writeRunner(fixture, session, { recordedAt: "2000-01-01T00:00:00Z" }); + return outcome("capture-outside-session", "page_evidence_capture_outside_session", finalizeSession(fixture, session, runner)); + }), + "missing-start-receipt": () => isolated((fixture) => { + const completed = completeSession(fixture); + fs.rmSync(path.join(completed.session.artifactRoot, "page-evidence-session.json")); + return outcome("missing-start-receipt", "page_evidence_session_missing", validateSession(fixture, completed.session)); + }), + "safe-reuse": () => isolated((fixture) => { + const setup = reuseSetup(fixture); + const runner = writeRunner(fixture, setup.current, { evidence: setup.evidence, writeArtifact: false }); + const finalized = finalizeSession(fixture, setup.current, runner, setup.priorManifest); + const validated = finalized.status === 0 ? validateSession(fixture, setup.current, setup.priorManifest) : finalized; + return outcome("safe-reuse", "ok:true", validated, { finalized: finalized.report.ok === true }); + }), + "unsafe-reuse": () => isolated((fixture) => { + const setup = reuseSetup(fixture); + setup.evidence.prior_manifest_sha256 = "0".repeat(64); + const runner = writeRunner(fixture, setup.current, { evidence: setup.evidence, writeArtifact: false }); + return outcome("unsafe-reuse", "page_evidence_reuse_manifest_mismatch", finalizeSession(fixture, setup.current, runner, setup.priorManifest)); + }), + "reuse-source-mismatch": () => isolated((fixture) => { + const setup = reuseSetup(fixture, { beforeCurrent: (consumer) => { + fs.appendFileSync(path.join(consumer.root, consumer.relevantSources[0]), "// next revision\n"); + execFileSync("git", ["add", consumer.relevantSources[0]], { cwd: consumer.root }); + execFileSync("git", ["commit", "-m", "next fixture revision"], { cwd: consumer.root }); + consumer.revision = execFileSync("git", ["rev-parse", "HEAD"], { cwd: consumer.root, encoding: "utf8" }).trim(); + } }); + const runner = writeRunner(fixture, setup.current, { evidence: setup.evidence, writeArtifact: false }); + return outcome("reuse-source-mismatch", "page_evidence_reuse_source_mismatch", finalizeSession(fixture, setup.current, runner, setup.priorManifest)); + }), + "reuse-scenario-mismatch": () => isolated((fixture) => { + const setup = reuseSetup(fixture, { currentOptions: { scenarioId: "different-scenario" } }); + const runner = writeRunner(fixture, setup.current, { evidence: setup.evidence, writeArtifact: false }); + return outcome("reuse-scenario-mismatch", "page_evidence_reuse_scenario_mismatch", finalizeSession(fixture, setup.current, runner, setup.priorManifest)); + }), + "reuse-environment-mismatch": () => isolated((fixture) => { + const setup = reuseSetup(fixture); + const changedEnvironment = { ...semanticEnvironment, viewport: { ...semanticEnvironment.viewport, width: 375 } }; + const runner = writeRunner(fixture, setup.current, { evidence: setup.evidence, semanticEnvironment: changedEnvironment, writeArtifact: false }); + return outcome("reuse-environment-mismatch", "page_evidence_reuse_environment_mismatch", finalizeSession(fixture, setup.current, runner, setup.priorManifest)); + }), + "downloaded-unchanged-session": () => isolated((fixture) => { + const completed = completeSession(fixture); + const download = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-page-download-"))); + try { + fs.cpSync(fixture.root, download, { recursive: true }); + const downloadedFixture = { ...fixture, root: download }; + const downloadedSession = { ...completed.session, artifactRoot: completed.session.artifactRoot.replace(fixture.root, download), recordFile: completed.session.recordFile.replace(fixture.root, download) }; + return outcome("downloaded-unchanged-session", "ok:true", validateSession(downloadedFixture, downloadedSession)); + } finally { fs.rmSync(download, { force: true, recursive: true }); } + }), + "past-review-by-advisory": () => isolated((fixture) => { + const completed = completeSession(fixture); + const manifestFile = path.join(completed.session.artifactRoot, "page-evidence-manifest.json"); + const manifest = JSON.parse(fs.readFileSync(manifestFile)); + manifest.review_by = "2000-01-01T00:00:00Z"; + writeJson(manifestFile, manifest); + return outcome("past-review-by-advisory", "ok:true", validateSession(fixture, completed.session)); + }), +}; + +const selected = requested.length > 0 ? requested : Object.keys(cases); +const results = selected.map((name) => cases[name]?.() ?? ({ actual: { codes: [], status: null }, expected: "known-case", name, ok: false })); +const failures = results.filter((result) => !result.ok).map((result) => `${result.name}:${result.expected}`); +const report = { adversarial: results.filter((result) => result.expected !== "ok:true").length, failures, ok: failures.length === 0, results }; +if (json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +else process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +if (!report.ok) process.exitCode = 1; diff --git a/scripts/test-validate-patterns.mjs b/scripts/test-validate-patterns.mjs index 71c05f7..af28966 100644 --- a/scripts/test-validate-patterns.mjs +++ b/scripts/test-validate-patterns.mjs @@ -4,8 +4,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; -const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const validator = path.join(root, "scripts", "validate-patterns.mjs"); const cases = [ { @@ -153,4 +154,4 @@ const report = { }; console.log(JSON.stringify(report, null, 2)); -process.exit(report.ok ? 0 : 1); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/test-validate-promotion-rfc.mjs b/scripts/test-validate-promotion-rfc.mjs new file mode 100644 index 0000000..a843f99 --- /dev/null +++ b/scripts/test-validate-promotion-rfc.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { copyTreeNoSymlinks, testPromotionBoundaries } from "./promotion-boundary-test-contract.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const fixtureRoot = path.join(repositoryRoot, "consumer-reference/fixtures/promotion"); +const validator = path.join(repositoryRoot, "scripts/validate-promotion-rfc.mjs"); + +function readJson(relative) { + return JSON.parse(fs.readFileSync(path.join(fixtureRoot, relative), "utf8")); +} + +function runArguments(args) { + const child = spawnSync(process.execPath, [validator, ...args], { cwd: repositoryRoot, encoding: "utf8" }); + let report = { failures: [], ok: false, parse_error: true }; + try { + report = JSON.parse(child.stdout); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + report = { failures: [], ok: false, parse_error: true }; + } + return { report, status: child.status }; +} + +function run(file, profileRepositoryRoot) { + const args = ["--file", file, "--json"]; + if (profileRepositoryRoot) args.push("--profile-repository-root", profileRepositoryRoot); + return runArguments(args); +} + +function runPolicy(file) { + return runArguments(["--policy", file, "--json"]); +} + +function writeMutation(name, source, mutate) { + const value = structuredClone(readJson(source)); + mutate(value); + const file = path.join(tempRoot, `${name}.json`); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); + return file; +} + +function writePolicyMutation(root, name, mutate) { + const value = JSON.parse(fs.readFileSync(path.join(repositoryRoot, "consumer-reference/policies/shared-experimental.json"), "utf8")); + mutate(value); + const file = path.join(root, `${name}.json`); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); + return file; +} + +function copyProfileRepository(name) { + const root = path.join(tempRoot, name); + const source = path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local"); + const target = path.join(root, "design-engineering/reference-profiles/governed-local"); + copyTreeNoSymlinks(source, target); + return root; +} + +function editProfileJson(root, relative, mutate) { + const file = path.join(root, relative); + const value = JSON.parse(fs.readFileSync(file, "utf8")); + mutate(value); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +const fixtureManifest = JSON.parse(fs.readFileSync(path.join(fixtureRoot, "manifest.json"), "utf8")); +const invalidCases = fixtureManifest.invalid_records.map((entry) => [entry.file.replace(/\.json$/, ""), entry.file, entry.expected_code]); + +const tempRoot = fs.mkdtempSync(path.join(repositoryRoot, ".tmp-pr6-promotion-")); +const repositoryTempRoot = fs.mkdtempSync(path.join(repositoryRoot, ".tmp-pr6-profile-")); +const results = []; +try { + for (const [name, relative, expected] of invalidCases) { + const child = run(path.join(fixtureRoot, relative)); + const codes = child.report.failures.map((failure) => failure.code); + results.push({ actual: { codes, scaffold: child.report.scaffold === true, status: child.status }, expected, name, ok: child.status > 0 && codes.includes(expected) && child.report.scaffold !== true }); + } + + const editorial = writeMutation("editorial_counted", "valid-deferred-example.json", (value) => { + value.attestations = [{ attested_by: "Synthetic owner", context_id: "editorial", consumer_id: "editorial-reference-profile", count_toward_gate: true, organization_id: "stylegallery", profile_record: "design-engineering/reference-profiles/governed-local/editorial/profile.json", relationship: "independent" }]; + }); + const terminal = writeMutation("terminal_counted", "valid-deferred-example.json", (value) => { + value.attestations = [{ attested_by: "Synthetic owner", context_id: "terminal", consumer_id: "terminal-reference-profile", count_toward_gate: true, organization_id: "stylegallery", profile_record: "design-engineering/reference-profiles/governed-local/terminal/profile.json", relationship: "independent" }]; + }); + const booleanReview = writeMutation("boolean_review", "valid-deferred-example.json", (value) => { value.review_independence = false; }); + const fakeIndependence = writeMutation("fake_independence", "valid-deferred-example.json", (value) => { + value.attestations = [ + { attested_by: "Synthetic owner", context_id: "one", consumer_id: "consumer-one", count_toward_gate: true, organization_id: "same-org", relationship: "independent" }, + { attested_by: "Synthetic owner", context_id: "two", consumer_id: "consumer-two", count_toward_gate: true, organization_id: "same-org", relationship: "independent" } + ]; + }); + const duplicateIdentity = writeMutation("duplicate_identity", "valid-deferred-example.json", (value) => { + const attestation = { attested_by: "Synthetic owner", context_id: "same", consumer_id: "same-consumer", count_toward_gate: true, organization_id: "same-org", relationship: "independent" }; + value.attestations = [attestation, structuredClone(attestation)]; + }); + const duplicateConsumer = writeMutation("duplicate_consumer", "valid-deferred-example.json", (value) => { + value.attestations = [ + { attested_by: "Synthetic owner A", context_id: "one", consumer_id: "same-consumer", count_toward_gate: true, organization_id: "organization-a", relationship: "independent" }, + { attested_by: "Synthetic owner B", context_id: "two", consumer_id: "same-consumer", count_toward_gate: true, organization_id: "organization-b", relationship: "independent" } + ]; + }); + const unknownConsumers = writeMutation("unknown_consumers", "valid-deferred-example.json", (value) => { + value.attestations = [ + { attested_by: "Synthetic owner A", context_id: "one", consumer_id: "unknown-one", count_toward_gate: true, organization_id: "organization-a", relationship: "independent" }, + { attested_by: "Synthetic owner B", context_id: "two", consumer_id: "unknown-two", count_toward_gate: true, organization_id: "organization-b", relationship: "independent" } + ]; + }); + const forgedOne = path.join(repositoryTempRoot, "forged-one.json"); + const forgedTwo = path.join(repositoryTempRoot, "forged-two.json"); + fs.writeFileSync(forgedOne, '{"fixture_independence":"independent","id":"forged-one"}\n'); + fs.writeFileSync(forgedTwo, '{"fixture_independence":"independent","id":"forged-two"}\n'); + const forgedProfiles = writeMutation("forged_profiles", "valid-deferred-example.json", (value) => { + value.attestations = [ + { attested_by: "Synthetic owner A", context_id: "one", consumer_id: "forged-one", count_toward_gate: true, organization_id: "organization-a", profile_record: path.relative(repositoryRoot, forgedOne), relationship: "independent" }, + { attested_by: "Synthetic owner B", context_id: "two", consumer_id: "forged-two", count_toward_gate: true, organization_id: "organization-b", profile_record: path.relative(repositoryRoot, forgedTwo), relationship: "independent" } + ]; + }); + const copiedProfileRoot = path.join(repositoryTempRoot, "copied"); + copyTreeNoSymlinks(path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local/editorial"), copiedProfileRoot); + const copiedUnregistered = writeMutation("copied_unregistered", "valid-deferred-example.json", (value) => { + value.attestations = [{ attested_by: "Synthetic owner", context_id: "copied", consumer_id: "editorial-reference-profile", count_toward_gate: true, organization_id: "copied-org", profile_record: path.relative(repositoryRoot, path.join(copiedProfileRoot, "profile.json")), relationship: "independent" }]; + }); + const invalidRegisteredRoot = copyProfileRepository("invalid-registered-root"); + editProfileJson(invalidRegisteredRoot, "design-engineering/reference-profiles/governed-local/editorial/profile.json", (value) => { delete value.owner; }); + const invalidRegistered = writeMutation("invalid_registered", "valid-deferred-example.json", (value) => { + value.attestations = [{ attested_by: "Synthetic owner", context_id: "invalid", consumer_id: "editorial-reference-profile", count_toward_gate: true, organization_id: "invalid-org", profile_record: "design-engineering/reference-profiles/governed-local/editorial/profile.json", relationship: "independent" }]; + }); + const duplicateRegistryRoot = copyProfileRepository("duplicate-registry-root"); + editProfileJson(duplicateRegistryRoot, "design-engineering/reference-profiles/governed-local/terminal/profile.json", (value) => { value.id = "editorial-reference-profile"; }); + const duplicateRegistryIdentity = writeMutation("duplicate_registry_identity", "valid-deferred-example.json", (value) => { + value.attestations = [{ attested_by: "Synthetic owner", context_id: "duplicate", consumer_id: "editorial-reference-profile", count_toward_gate: true, organization_id: "duplicate-org", profile_record: "design-engineering/reference-profiles/governed-local/editorial/profile.json", relationship: "independent" }]; + }); + const silentRelabel = writeMutation("silent_relabel", "valid-deferred-example.json", (value) => { + value.transition = { from: "stable", to: "shared_experimental" }; + }); + const pathEscape = writeMutation("path_escape", "valid-deferred-example.json", (value) => { + value.attestations = [{ attested_by: "Synthetic owner", context_id: "escape", consumer_id: "escape", count_toward_gate: true, organization_id: "escape", profile_record: "../outside.json", relationship: "independent" }]; + }); + const profileMismatch = writeMutation("profile_mismatch", "valid-deferred-example.json", (value) => { + value.attestations = [{ attested_by: "Synthetic owner", context_id: "editorial", consumer_id: "different-profile", count_toward_gate: true, organization_id: "stylegallery", profile_record: "design-engineering/reference-profiles/governed-local/editorial/profile.json", relationship: "independent" }]; + }); + const profileLink = path.join(repositoryTempRoot, "profile.json"); + fs.symlinkSync(path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local/editorial/profile.json"), profileLink); + const profileSymlink = writeMutation("profile_symlink", "valid-deferred-example.json", (value) => { + value.attestations = [{ attested_by: "Synthetic owner", context_id: "editorial", consumer_id: "editorial-reference-profile", count_toward_gate: true, organization_id: "stylegallery", profile_record: path.relative(repositoryRoot, profileLink), relationship: "independent" }]; + }); + const schemaExtra = writeMutation("schema_extra", "valid-deferred-example.json", (value) => { value.unexpected_promotion = true; }); + const acceptedDecision = writeMutation("accepted_decision", "valid-deferred-example.json", (value) => { value.decision = "accepted"; }); + const aestheticPromotion = writeMutation("aesthetic_promotion", "valid-deferred-example.json", (value) => { value.scope.aesthetic = true; }); + const breakingWithoutMigration = writeMutation("breaking_without_migration", "valid-deferred-example.json", (value) => { value.compatibility.breaking = true; value.migration.required = false; }); + const normativeWithoutRollback = writeMutation("normative_without_rollback", "valid-normative-bypass.json", (value) => { delete value.rollback; }); + const normativeEndedSupport = writeMutation("normative_ended_support", "valid-normative-bypass.json", (value) => { + value.support = { capacity: "No active support capacity", status: "ended" }; + }); + const normativeMissingSupport = writeMutation("normative_missing_support", "valid-normative-bypass.json", (value) => { delete value.support; }); + const normativeMissingCapacity = writeMutation("normative_missing_capacity", "valid-normative-bypass.json", (value) => { delete value.support.capacity; }); + const weakCapacityCases = ["0", "Not available", "Absent", "Lapsed", "Negative capacity"].map((capacity, index) => [ + `weak_capacity_${index + 1}`, + writeMutation(`weak_capacity_${index + 1}`, "valid-deferred-example.json", (value) => { value.support.capacity = capacity; }), + "promotion_support_required", + ]); + const stableWithoutCount = writeMutation("stable_without_count", "valid-deferred-example.json", (value) => { + value.transition = { from: "shared_experimental", to: "stable" }; + value.stable_scope_decision = true; + value.provenance.debt_resolved = true; + }); + const stableWithoutEvidence = writeMutation("stable_without_evidence", "valid-deferred-example.json", (value) => { + value.transition = { from: "shared_experimental", to: "stable" }; + value.stable_scope_decision = true; + value.provenance.debt_resolved = true; + value.evidence = [{ id: "regression", kind: "regression", reference: "synthetic-fixture:regression", status: "failed" }]; + }); + const duplicateKey = path.join(tempRoot, "duplicate_key.json"); + fs.writeFileSync(duplicateKey, fs.readFileSync(path.join(fixtureRoot, "valid-deferred-example.json"), "utf8").replace('"decision": "deferred",', '"decision": "deferred",\n "decision": "rejected",')); + for (const [name, file, expected, profileRepositoryRoot] of [ + ["editorial_counted", editorial, "promotion_related_consumer_counted"], + ["terminal_counted", terminal, "promotion_related_consumer_counted"], + ["boolean_review", booleanReview, "review_independence_boolean"], + ["fake_independence", fakeIndependence, "promotion_independence_unproven"], + ["duplicate_identity", duplicateIdentity, "promotion_attestation_duplicate"], + ["duplicate_consumer", duplicateConsumer, "promotion_attestation_duplicate"], + ["unknown_consumers", unknownConsumers, "promotion_independence_unproven"], + ["forged_profiles", forgedProfiles, "promotion_independence_unproven"], + ["copied_unregistered", copiedUnregistered, "promotion_independence_unproven"], + ["invalid_registered", invalidRegistered, "promotion_independence_unproven", invalidRegisteredRoot], + ["duplicate_registry_identity", duplicateRegistryIdentity, "promotion_independence_unproven", duplicateRegistryRoot], + ["silent_relabel", silentRelabel, "promotion_stable_relabel_forbidden"], + ["path_escape", pathEscape, "promotion_profile_path_invalid"], + ["profile_mismatch", profileMismatch, "promotion_profile_identity_mismatch"], + ["profile_symlink", profileSymlink, "promotion_profile_path_symlink"], + ["schema_extra", schemaExtra, "promotion_schema_invalid"], + ["accepted_decision", acceptedDecision, "promotion_schema_invalid"], + ["aesthetic_promotion", aestheticPromotion, "promotion_schema_invalid"], + ["breaking_without_migration", breakingWithoutMigration, "promotion_migration_required"], + ["normative_without_rollback", normativeWithoutRollback, "promotion_schema_invalid"], + ["normative_ended_support", normativeEndedSupport, "promotion_support_required"], + ["normative_missing_support", normativeMissingSupport, "promotion_support_required"], + ["normative_missing_capacity", normativeMissingCapacity, "promotion_support_required"], + ["duplicate_key", duplicateKey, "promotion_json_invalid"], + ["stable_without_evidence", stableWithoutEvidence, "promotion_stable_evidence_required"], + ...weakCapacityCases, + ]) { + const child = run(file, profileRepositoryRoot); + const codes = child.report.failures.map((failure) => failure.code); + results.push({ actual: { codes, scaffold: child.report.scaffold === true, status: child.status }, expected, name, ok: child.status > 0 && codes.includes(expected) && child.report.scaffold !== true }); + } + + for (const relative of ["valid-deferred-example.json", "valid-normative-bypass.json"]) { + const child = run(path.join(fixtureRoot, relative)); + results.push({ actual: { scaffold: child.report.scaffold === true, status: child.status }, expected: "ok:true", name: relative, ok: child.status === 0 && child.report.ok === true && child.report.scaffold !== true }); + } + const stable = run(stableWithoutCount); + results.push({ actual: { scaffold: stable.report.scaffold === true, status: stable.status }, expected: "stable_without_numeric_threshold", name: "stable_without_count", ok: stable.status === 0 && stable.report.ok === true && stable.report.scaffold !== true }); + const canonicalPolicy = runPolicy(path.join(repositoryRoot, "consumer-reference/policies/shared-experimental.json")); + results.push({ actual: { status: canonicalPolicy.status }, expected: "canonical_policy_valid", name: "canonical_policy", ok: canonicalPolicy.status === 0 && canonicalPolicy.report.ok === true }); + results.push(...testPromotionBoundaries({ fixtureRoot, repositoryRoot, runArguments, tempRoot })); + for (const [name, file, expected] of [ + ["stable_numeric_count", writePolicyMutation(tempRoot, "stable_numeric_count", (value) => { value.gateway.stable_numeric_threshold = 2; }), "promotion_stable_numeric_threshold_forbidden"], + ["bypass_overreach", writePolicyMutation(tempRoot, "bypass_overreach", (value) => { value.normative_bypass.waives.push("regression_proof"); }), "promotion_normative_bypass_overreach"], + ["fake_promotion_claim", writePolicyMutation(tempRoot, "fake_promotion_claim", (value) => { value.claims.promotion_occurred = true; }), "promotion_claim_forbidden"], + ["policy_unknown_field", writePolicyMutation(tempRoot, "policy_unknown_field", (value) => { value.accepted = true; }), "promotion_policy_shape_invalid"], + ["policy_nested_unknown", writePolicyMutation(tempRoot, "policy_nested_unknown", (value) => { value.gateway.stable_by_count = true; }), "promotion_policy_shape_invalid"], + ["policy_promotes_palette", writePolicyMutation(tempRoot, "policy_promotes_palette", (value) => { value.boundaries.promoted_content.push("palette"); }), "promotion_aesthetic_boundary_required"], + ["policy_promotes_alias", writePolicyMutation(tempRoot, "policy_promotes_alias", (value) => { value.boundaries.promoted_content.push("brand_identity"); }), "promotion_aesthetic_boundary_required"], + ["policy_promoted_non_string", writePolicyMutation(tempRoot, "policy_promoted_non_string", (value) => { value.boundaries.promoted_content = [42]; }), "promotion_aesthetic_boundary_required"], + ["policy_normative_aesthetic", writePolicyMutation(tempRoot, "policy_normative_aesthetic", (value) => { value.normative_bypass.categories = ["aesthetic"]; }), "promotion_normative_bypass_overreach"], + ["policy_lifecycle_incomplete", writePolicyMutation(tempRoot, "policy_lifecycle_incomplete", (value) => { value.lifecycle.failure_actions = ["deprecate_with_migration"]; }), "promotion_lifecycle_invalid"], + ["policy_excluded_non_string", writePolicyMutation(tempRoot, "policy_excluded_non_string", (value) => { value.boundaries.excluded_content.push(42); }), "promotion_aesthetic_boundary_required"], + ["policy_retains_non_string", writePolicyMutation(tempRoot, "policy_retains_non_string", (value) => { value.normative_bypass.retains.push(42); }), "promotion_normative_bypass_overreach"], + ["policy_verified_owner", writePolicyMutation(tempRoot, "policy_verified_owner", (value) => { value.owner.enforcement = "verified"; }), "promotion_policy_owner_invalid"], + ["policy_promoted_status", writePolicyMutation(tempRoot, "policy_promoted_status", (value) => { value.status = "promoted"; }), "promotion_claim_forbidden"], + ["policy_generated_mode", writePolicyMutation(tempRoot, "policy_generated_mode", (value) => { value.artifact_mode = "generated"; }), "promotion_policy_identity_invalid"], + ]) { + const child = runPolicy(file); + const codes = child.report.failures.map((failure) => failure.code); + results.push({ actual: { codes, status: child.status }, expected, name, ok: child.status > 0 && codes.includes(expected) }); + } +} finally { + fs.rmSync(tempRoot, { force: true, recursive: true }); + fs.rmSync(repositoryTempRoot, { force: true, recursive: true }); +} + +const failures = results.filter((result) => !result.ok).map((result) => `missing_semantic:${result.name}:${result.expected}`); +const report = { failures, ok: failures.length === 0, results }; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +if (!report.ok) process.exitCode = 1; diff --git a/scripts/test-validate-reference-profiles.mjs b/scripts/test-validate-reference-profiles.mjs new file mode 100644 index 0000000..4d882b9 --- /dev/null +++ b/scripts/test-validate-reference-profiles.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const validator = path.join(repositoryRoot, "scripts", "validate-consumer-reference.mjs"); +const profilePath = "design-engineering/reference-profiles/governed-local/editorial/profile.json"; +const terminalProfilePath = "design-engineering/reference-profiles/governed-local/terminal/profile.json"; +const aliasProfilePath = "design-engineering/reference-profiles/governed-local/alias/profile.json"; +const layoutSha = "775430bbaf4ee208a642220f440f6926d79c90a3"; + +const baseProfile = { + artifact_mode: "governed_local", + component_records: ["components/button.component.json"], + default: false, + environment_assumptions: { + reset: { body_margin: "0", box_sizing: "border-box", figure_margin: "0" }, + user_agent_styles: "Preserve browser defaults except for the declared reset.", + }, + example_only: true, + evidence_records: ["evidence/button.evidence.json"], + fixture_records: ["fixtures/button.fixture.json"], + fixture_independence: "related", + handoff: { record: profilePath, status: "declared" }, + generated_records: ["generated/evidence-coverage.md", "generated/keyboard-matrix.md", "generated/state-matrix.md"], + id: "editorial-reference-profile", + layout_source_sha: layoutSha, + local_foundations: "local-foundations.json", + maturity: "experimental", + owner: { enforcement: "placeholder", name: "Design Engineering owner" }, + profile_kind: "governed_local", + related_fixture_set_id: "layout-identity-adversarial-pair", + review_independence: "single_account", + schema_version: "1.0", + selection: { method: "profile_path", required: true }, + state_records: ["states/button.states.json"], + support: { status: "active" }, + tokens: "tokens.dtcg.json", +}; + +const tokens = { + color: { + $type: "color", + accent: { $value: { alpha: 1, colorSpace: "srgb", components: [0.55, 0.18, 0.1] } }, + }, + space: { $type: "dimension", page: { $value: { unit: "rem", value: 1.5 } } }, +}; +const terminalTokens = { + color: { + $type: "color", + accent: { $value: { alpha: 1, colorSpace: "srgb", components: [0.1, 0.9, 0.5] } }, + }, + space: { $type: "dimension", page: { $value: { unit: "rem", value: 1 } } }, +}; +const editorialFoundations = { bindings: { accent: "{color.accent}" }, identity: "Warm editorial", profile_id: "editorial-reference-profile" }; +const terminalFoundations = { bindings: { accent: "{color.accent}" }, identity: "Dark terminal", profile_id: "terminal-reference-profile" }; + +const cases = [ + { name: "valid_governed_local_profile" }, + { expect: "profile_artifact_mode_governed_local", name: "local_as_external", mutate: (item) => { item.artifact_mode = "external_consumer"; } }, + { expect: "profile_default_forbidden", name: "implicit_default", mutate: (item) => { item.default = true; } }, + { expect: "profile_layout_source_sha_required", name: "missing_layout_sha", mutate: (item) => { delete item.layout_source_sha; } }, + { expect: "profile_ua_assumption_required", name: "missing_ua_assumption", mutate: (item) => { delete item.environment_assumptions.user_agent_styles; } }, + { expect: "profile_reset_assumptions_required", name: "missing_reset_assumptions", mutate: (item) => { delete item.environment_assumptions.reset; } }, + { expect: "profile_explicit_selection_required", name: "implicit_selection", mutate: (item) => { item.selection.required = false; } }, + { expect: "fixture_independence_related", name: "independence_independent", mutate: (item) => { item.fixture_independence = "independent"; } }, + { expect: "reverse_import", name: "reverse_profile_import", patternData: "export const related_fixture_set_id = 'layout-identity-adversarial-pair';\n" }, + { expect: "external_adaptation_record_forbidden", externalRecord: true, name: "durable_external_adopter" }, + { equalTokens: true, expect: "profile_tokens_not_distinct", name: "single_profile_equal_pair_tokens" }, + { equalFoundations: true, expect: "profile_foundations_not_distinct", name: "single_profile_equal_pair_foundations" }, + { equalIdentity: true, expect: "profile_identity_not_distinct", name: "single_profile_equal_pair_identity" }, + { expect: "profile_related_set_mismatch", name: "single_profile_related_set_mismatch", mutateTerminal: (item) => { item.related_fixture_set_id = "unrelated-terminal-set"; } }, + { expect: "profile_tokens_symlink_escape", linkedArtifact: "tokens", name: "linked_outside_tokens" }, + { expect: "profile_foundations_symlink_escape", linkedArtifact: "foundations", name: "linked_outside_foundations" }, + { expect: "profile_environment_property_unknown", name: "unknown_environment_field", mutate: (item) => { item.environment_assumptions.viewport = "wide"; } }, + { expect: "profile_reset_property_unknown", name: "unknown_reset_field", mutate: (item) => { item.environment_assumptions.reset.list_margin = "0"; } }, + { expect: "profile_selection_property_unknown", name: "unknown_selection_field", mutate: (item) => { item.selection.fallback = "editorial"; } }, + { expect: "item_property_unknown", name: "unknown_profile_top_level", mutate: (item) => { item.profile_fallback = true; } }, + { + equalTokens: true, + expect: "profile_tokens_not_distinct", + name: "normalized_equivalent_selector_loads_pair", + selector: "design-engineering/reference-profiles/governed-local/editorial/../editorial/profile.json", + }, + { absoluteSelector: true, expect: "item_unresolved", name: "absolute_profile_selector_rejected" }, + { expect: "item_unresolved", name: "scheme_profile_selector_rejected", selector: "https://example.com/editorial/profile.json" }, + { expect: "item_unresolved", name: "network_profile_selector_rejected", selector: "//server/editorial/profile.json" }, + { expect: "item_unresolved", name: "escaping_profile_selector_rejected", selector: "../editorial/profile.json" }, + { aliasSelector: true, expect: "item_redirect", name: "symlink_alias_profile_selector_rejected" }, +]; + +function writeFixture(testCase) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `stylegallery-reference-profile-${testCase.name}-`)); + const profile = structuredClone(baseProfile); + const terminalProfile = structuredClone(baseProfile); + terminalProfile.handoff.record = terminalProfilePath; + terminalProfile.id = "terminal-reference-profile"; + testCase.mutate?.(profile); + testCase.mutateTerminal?.(terminalProfile); + const terminalFoundation = structuredClone(terminalFoundations); + if (testCase.equalIdentity) terminalFoundation.identity = editorialFoundations.identity; + const files = { + "CATALOG.md": "# Catalog\n", + [profilePath]: `${JSON.stringify(profile, null, 2)}\n`, + "design-engineering/reference-profiles/governed-local/editorial/local-foundations.json": `${JSON.stringify(editorialFoundations)}\n`, + "design-engineering/reference-profiles/governed-local/editorial/tokens.dtcg.json": `${JSON.stringify(tokens, null, 2)}\n`, + [terminalProfilePath]: `${JSON.stringify(terminalProfile, null, 2)}\n`, + "design-engineering/reference-profiles/governed-local/terminal/local-foundations.json": `${JSON.stringify(testCase.equalFoundations ? editorialFoundations : terminalFoundation)}\n`, + "design-engineering/reference-profiles/governed-local/terminal/tokens.dtcg.json": `${JSON.stringify(testCase.equalTokens ? tokens : terminalTokens, null, 2)}\n`, + "layout/index.md": "# Layout\n", + "patterns/index.md": "# Patterns\n", + "quality/handoff.md": "Implementation handoff:\nConsumer reference: not_applicable\nConsumer reference reason: This fixture has no consumer-specific reference record.\n", + "scripts/pattern-data.mjs": testCase.patternData ?? "export const patterns = [];\n", + }; + if (testCase.externalRecord) files["design-engineering/reference-profiles/external-adaptation/adopter.json"] = "{}\n"; + for (const [relative, content] of Object.entries(files)) { + const target = path.join(root, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + } + let externalRoot; + if (testCase.linkedArtifact) { + externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-reference-profile-outside-")); + const filename = testCase.linkedArtifact === "tokens" ? "tokens.dtcg.json" : "local-foundations.json"; + const target = path.join(root, path.posix.dirname(profilePath), filename); + const external = path.join(externalRoot, filename); + fs.writeFileSync(external, files[path.posix.join(path.posix.dirname(profilePath), filename)]); + fs.rmSync(target); + fs.symlinkSync(external, target); + } + let selector = testCase.selector ?? profilePath; + if (testCase.absoluteSelector) selector = path.join(root, profilePath); + if (testCase.aliasSelector) { + const alias = path.join(root, aliasProfilePath); + fs.mkdirSync(path.dirname(alias), { recursive: true }); + fs.symlinkSync(path.join(root, profilePath), alias); + selector = aliasProfilePath; + } + return { externalRoot, root, selector }; +} + +function runCase(testCase) { + const fixture = writeFixture(testCase); + try { + const child = spawnSync(process.execPath, [validator, "--profile", fixture.selector, "--json"], { cwd: fixture.root, encoding: "utf8" }); + const output = JSON.parse(child.stdout); + const codes = Array.isArray(output.failures) ? output.failures.map((failure) => failure.code) : []; + const accepted = child.status === 0 && output.ok === true && output.scaffold !== true; + const rejected = child.status !== 0 && output.ok === false && codes.includes(testCase.expect); + return { + actual: { codes, ok: output.ok, status: child.status }, + expected: testCase.expect ?? "ok:true and exit:0", + name: testCase.name, + ok: testCase.expect ? rejected : accepted, + }; + } finally { + fs.rmSync(fixture.root, { force: true, recursive: true }); + if (fixture.externalRoot) fs.rmSync(fixture.externalRoot, { force: true, recursive: true }); + } +} + +const results = cases.map(runCase); +const failures = results.filter((result) => !result.ok).map((result) => `missing_semantic:${result.name}:${result.expected}`); +const report = { failures, ok: failures.length === 0, results }; +console.log(JSON.stringify(report, null, 2)); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/test-validate-vocabulary.mjs b/scripts/test-validate-vocabulary.mjs index acfa384..8c8ee0a 100644 --- a/scripts/test-validate-vocabulary.mjs +++ b/scripts/test-validate-vocabulary.mjs @@ -4,8 +4,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; -const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const validator = path.join(root, "scripts", "validate-vocabulary.mjs"); const glossary = [ @@ -170,4 +171,4 @@ const report = { }; console.log(JSON.stringify(report, null, 2)); -process.exit(report.ok ? 0 : 1); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/test-validate-webpage-workflow.mjs b/scripts/test-validate-webpage-workflow.mjs index 7cda0fc..f76fb4f 100644 --- a/scripts/test-validate-webpage-workflow.mjs +++ b/scripts/test-validate-webpage-workflow.mjs @@ -4,8 +4,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; -const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const validator = path.join(root, "scripts", "validate-webpage-workflow.mjs"); const files = { @@ -129,6 +130,42 @@ const files = { "Final implementation proof:", "", ].join("\n"), + "design-engineering/consumer-migration-readiness.md": [ + "---", + "lifecycle: experimental", + "provenance_kind: local", + "---", + "# Consumer Migration Readiness", + "## Migration Dimension Contract", + "`behavior_inventory`", + "`route_parity`", + "`field_parity`", + "`action_parity`", + "`state_transitions`", + "`contract_precedence`", + "`direct_mutation`", + "`indirect_mutation`", + "`persistence_round_trip`", + "`reset_boundary`", + "`exact_time_boundary`", + "`defaults_tri_state_mapping`", + "`atomic_batch_behavior`", + "Consumer migration conformance: declared", + "Passing this method does not prove complete accessibility, product correctness, usability, visual quality, independent adoption, cross-browser equivalence, or owner approval.", + ].join("\n"), + "consumer-reference/contract.md": [ + "### Migration-Only Extension", + "This field is migration-only.", + "Do not add it to generic blank handoffs", + "Consumer migration conformance: declared", + ].join("\n"), + "quality/gates/consumer-migration-evidence.md": [ + "# Consumer Migration Evidence Gate", + "## Required Contract", + "## Blocking Conditions", + "thirteen migration dimensions", + "Decision: pass | block", + ].join("\n"), }; const cases = [ @@ -185,6 +222,20 @@ const cases = [ }, expect: "guides/webpage-generation-workflow.md: missing Consumer reference reason:", }, + { + name: "missing_migration_dimension", + mutate: { + "design-engineering/consumer-migration-readiness.md": files["design-engineering/consumer-migration-readiness.md"].replace("`atomic_batch_behavior`", "`batch_behavior`"), + }, + expect: "design-engineering/consumer-migration-readiness.md: missing `atomic_batch_behavior`", + }, + { + name: "migration_field_in_ordinary_handoff", + mutate: { + "guides/webpage-generation-workflow.md": `${files["guides/webpage-generation-workflow.md"]}\nConsumer migration conformance: declared\n`, + }, + expect: "guides/webpage-generation-workflow.md: ordinary handoff must not declare consumer migration conformance", + }, { name: "paraphrased_navigation_label", mutate: { @@ -239,4 +290,4 @@ const report = { }; console.log(JSON.stringify(report, null, 2)); -process.exit(report.ok ? 0 : 1); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/validate-baseline-manifest.mjs b/scripts/validate-baseline-manifest.mjs new file mode 100644 index 0000000..8b50900 --- /dev/null +++ b/scripts/validate-baseline-manifest.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; +import { readJson, validateCalibration, validateManifest } from "./baseline-contract.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const options = { calibration: "consumer-reference/baselines/calibration.json", json: false, manifest: "consumer-reference/baselines/manifest.json" }; +const failures = []; +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") options.json = true; + else if (argument === "--manifest" || argument === "--calibration") { + const value = process.argv[index + 1]; + if (!value) failures.push({ code: "argument_value_required", message: `${argument} requires a value`, path: "" }); + else { + options[argument.slice(2)] = value; + index += 1; + } + } else failures.push({ code: "argument_unknown", message: `unsupported argument ${argument}`, path: "" }); +} + +const manifestPath = path.resolve(repositoryRoot, options.manifest); +const calibrationPath = path.resolve(repositoryRoot, options.calibration); +const manifestSchemaPath = path.join(repositoryRoot, "consumer-reference/schema/baseline-manifest.schema.json"); +const calibrationSchemaPath = path.join(repositoryRoot, "consumer-reference/schema/calibration-record.schema.json"); +const manifest = readJson(manifestPath, failures); +const calibration = readJson(calibrationPath, failures); +const manifestSchema = readJson(manifestSchemaPath, failures); +const calibrationSchema = readJson(calibrationSchemaPath, failures); +const ajv = new Ajv2020({ allErrors: true, strict: false }); +for (const [kind, value, schema, file] of [["manifest", manifest, manifestSchema, manifestPath], ["calibration", calibration, calibrationSchema, calibrationPath]]) { + if (value === undefined || schema === undefined) continue; + const validate = ajv.compile(schema); + if (!validate(value)) for (const error of validate.errors ?? []) failures.push({ code: `baseline_${kind}_schema_invalid`, message: `${error.instancePath || "/"} ${error.message}`, path: file }); +} +if (manifest !== undefined) failures.push(...validateManifest(manifest, manifestPath, repositoryRoot)); +if (calibration !== undefined) failures.push(...validateCalibration(calibration, calibrationPath)); +const uniqueFailures = [...new Map(failures.map((failure) => [`${failure.code}:${failure.path}:${failure.message}`, failure])).values()]; +const result = { failures: uniqueFailures, ok: uniqueFailures.length === 0, warnings: [] }; +if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +else if (!result.ok) process.stderr.write(`${result.failures.map((failure) => `${failure.code}: ${failure.message}`).join("\n")}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/scripts/validate-catalog.mjs b/scripts/validate-catalog.mjs index e5dae6b..bde3c9c 100644 --- a/scripts/validate-catalog.mjs +++ b/scripts/validate-catalog.mjs @@ -62,4 +62,4 @@ if (json) { console.error(result.failures.join("\n")); } -process.exit(result.ok ? 0 : 1); +process.exitCode = result.ok ? 0 : 1; diff --git a/scripts/validate-component-state.mjs b/scripts/validate-component-state.mjs new file mode 100644 index 0000000..0a2bab1 --- /dev/null +++ b/scripts/validate-component-state.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + canonicalSourceManifest, + canonicalIntended, + readCaptureSession, + sameJson, + sourceManifestMatches, + withinSession, +} from "./capture-session-contract.mjs"; +import { compileSchemas, readRecord, validateEvidenceArtifacts, validateProfile } from "./component-state-contract.mjs"; +import { resolveProfileRecords } from "./profile-record-contract.mjs"; +import { visualExpectationFor } from "./visual-expectation-contract.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const options = { artifactRoot: undefined, json: false, root: path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local"), runtimeManifest: undefined }; +const failures = []; + +function validateCommittedVisualExpectations(resolved) { + const evidenceRecord = resolved.records.evidence[0]; + const statesRecord = resolved.records.states[0]; + if (!evidenceRecord || !statesRecord) return; + const scenarios = new Map((statesRecord.value.scenarios ?? []).map((scenario) => [scenario.id, scenario])); + for (const pass of (evidenceRecord.value.passes ?? []).filter((candidate) => candidate.channel === "visual")) { + const scenario = scenarios.get(pass.scenario_id); + if (!scenario) continue; + try { + const expected = visualExpectationFor(scenario, pass.environment, statesRecord.value.visual_environments ?? []); + const actual = pass.artifact ?? {}; + if (actual.sha256 !== expected.sha256 || actual.width !== expected.width || actual.height !== expected.height) { + failures.push({ code: "evidence_visual_expectation_mismatch", message: `${pass.id} committed visual metadata differs from its canonical environment expectation`, path: evidenceRecord.path }); + } + } catch (error) { + failures.push({ code: "evidence_visual_expectation_invalid", message: error instanceof Error ? error.message : String(error), path: evidenceRecord.path }); + } + } +} + +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") options.json = true; + else if (["--artifact-root", "--runtime-manifest", "--root"].includes(argument)) { + const value = process.argv[index + 1]; + if (!value) failures.push({ code: "argument_value_required", message: `${argument} requires a path`, path: "" }); + else { + if (argument === "--artifact-root") options.artifactRoot = path.resolve(value); + if (argument === "--runtime-manifest") options.runtimeManifest = path.resolve(value); + if (argument === "--root") options.root = path.resolve(value); + index += 1; + } + } else failures.push({ code: "argument_unknown", message: `unsupported argument ${argument}`, path: "" }); +} + +const schemas = compileSchemas(path.join(repositoryRoot, "consumer-reference/schema")); +let runtimeManifest; +let capture; +let canonicalSource; +if (!options.runtimeManifest) { + try { canonicalSource = canonicalSourceManifest(repositoryRoot, options.root); } + catch (error) { failures.push({ code: "capture_source_unreadable", message: error instanceof Error ? error.message : String(error), path: options.root }); } +} +if (options.runtimeManifest) { + runtimeManifest = readRecord(options.runtimeManifest, failures); + if (!options.artifactRoot) failures.push({ code: "runtime_manifest_artifact_root_required", message: "--runtime-manifest requires --artifact-root", path: "" }); + if (runtimeManifest && !schemas.runtime(runtimeManifest)) for (const error of schemas.runtime.errors ?? []) failures.push({ code: "runtime_manifest_schema_invalid", message: `${error.instancePath || "/"} ${error.message}`, path: options.runtimeManifest }); + if (options.artifactRoot) capture = readCaptureSession(path.join(options.artifactRoot, "capture-session.json"), schemas.capture, failures); + if (capture && runtimeManifest) { + const session = runtimeManifest.session; + if (!session || !sameJson(session.receipt, capture.artifact) || session.receipt_sha256 !== capture.digest) failures.push({ code: "capture_session_receipt_mismatch", message: "manifest receipt metadata does not match capture-session.json", path: options.runtimeManifest }); + const expectedLink = capture.link; + for (const key of ["session_id", "nonce", "started_at", "revision", "branch", "attempt", "environment", "source"]) if (!sameJson(session?.[key], expectedLink[key])) failures.push({ code: "capture_session_mismatch", message: `manifest ${key} differs from receipt`, path: options.runtimeManifest }); + for (const key of ["repository", "intended"]) if (!sameJson(session?.[key], capture.receipt[key])) failures.push({ code: "capture_session_mismatch", message: `manifest ${key} differs from receipt`, path: options.runtimeManifest }); + if (runtimeManifest.recorded_at !== session?.completed_at) failures.push({ code: "evidence_recorded_at_mismatch", message: "manifest recorded_at must equal session completed_at", path: options.runtimeManifest }); + if (!withinSession(session?.completed_at, capture.receipt.started_at, session?.completed_at)) failures.push({ code: "capture_session_time_outside", message: "completed_at precedes session start", path: options.runtimeManifest }); + const canonical = canonicalIntended(options.root, failures); + if (!sameJson(capture.receipt.intended, canonical)) failures.push({ code: "capture_session_intent_mismatch", message: "receipt intent differs from current canonical records", path: options.runtimeManifest }); + if (!capture.receipt.source) failures.push({ code: "capture_source_missing", message: "capture receipt must include its canonical source manifest", path: options.runtimeManifest }); + else if (!sourceManifestMatches(capture.receipt.source, repositoryRoot, options.root)) failures.push({ code: "capture_source_drift", message: "current capture sources differ from the receipt source manifest", path: options.runtimeManifest }); + const run = runtimeManifest.run; + if (run?.id !== capture.receipt.session_id || run?.repository !== capture.receipt.repository || run?.revision !== capture.receipt.revision || run?.attempt !== capture.receipt.attempt) failures.push({ code: "capture_session_mismatch", message: "manifest run differs from receipt", path: options.runtimeManifest }); + for (const record of runtimeManifest.records ?? []) { + for (const pass of record.passes ?? []) { + if (!sameJson(pass.environment, runtimeManifest.environment) || !sameJson(pass.run, run)) failures.push({ code: "runtime_manifest_identity_mismatch", message: `${pass.id} does not match the closed manifest identity`, path: options.runtimeManifest }); + if (!sameJson(pass.session, capture.link) || !sameJson(pass.environment, capture.receipt.environment) || !sameJson(pass.run, run)) failures.push({ code: "capture_session_mismatch", message: `${pass.id} pass identity differs from receipt`, path: options.runtimeManifest }); + } + } + } +} else if (options.artifactRoot) failures.push({ code: "runtime_manifest_required", message: "artifact validation requires --runtime-manifest", path: "" }); + +const profiles = fs.existsSync(options.root) + ? fs.readdirSync(options.root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && fs.existsSync(path.join(options.root, entry.name, "profile.json"))).map((entry) => entry.name).sort() + : []; +if (profiles.length === 0) failures.push({ code: "profile_root_empty", message: "governed-local root must contain profiles", path: options.root }); +for (const profile of profiles) { + const profileRoot = path.join(options.root, profile); + failures.push(...validateProfile(profileRoot, schemas)); + if (!runtimeManifest) { + const resolved = resolveProfileRecords(profileRoot, failures); + if (resolved) validateCommittedVisualExpectations(resolved); + const evidenceRecord = resolved?.records.evidence[0]; + if (canonicalSource) { + for (const pass of evidenceRecord?.value.passes ?? []) { + if (!pass.session?.source) failures.push({ code: "capture_source_missing", message: `${pass.id} committed evidence must include its canonical source manifest`, path: evidenceRecord.path }); + else if (!sameJson(pass.session.source, canonicalSource)) failures.push({ code: "capture_source_drift", message: `${pass.id} committed evidence source differs from current canonical capture sources`, path: evidenceRecord.path }); + } + } + } + const profileId = JSON.parse(fs.readFileSync(path.join(profileRoot, "profile.json"), "utf8")).id; + const matching = runtimeManifest?.records?.filter((record) => record.profile_id === profileId) ?? []; + if (runtimeManifest && matching.length !== 1) failures.push({ code: "runtime_manifest_profile_count", message: `${profileId} requires exactly one runtime evidence record`, path: options.runtimeManifest }); + if (options.artifactRoot && runtimeManifest) failures.push(...validateEvidenceArtifacts(profileRoot, options.artifactRoot, matching[0], schemas, runtimeManifest.session)); +} +if (runtimeManifest) { + const known = new Set(profiles.map((profile) => JSON.parse(fs.readFileSync(path.join(options.root, profile, "profile.json"), "utf8")).id)); + for (const record of runtimeManifest.records ?? []) if (!known.has(record.profile_id)) failures.push({ code: "runtime_manifest_profile_unknown", message: `${record.profile_id} is not a governed profile`, path: options.runtimeManifest }); +} +if (runtimeManifest && options.artifactRoot) { + const passes = (runtimeManifest.records ?? []).flatMap((record) => record.passes ?? []); + const passArtifacts = new Set(passes.map((pass) => pass.artifact?.path).filter(Boolean)); + if (passArtifacts.size !== 30) failures.push({ code: "runtime_channel_count_mismatch", message: `manifest requires exactly 30 unique channel artifacts, found ${passArtifacts.size}`, path: options.runtimeManifest }); + const expected = new Set(passArtifacts); + for (const pass of passes) if (pass.channel === "visual" && pass.artifact?.path?.endsWith(".png")) expected.add(pass.artifact.path.replace(/\.png$/, ".visual.json")); + if (expected.size !== 40) failures.push({ code: "runtime_artifact_count_mismatch", message: `manifest requires exactly 40 runtime files, found ${expected.size}`, path: options.runtimeManifest }); + const runtimeRoot = path.join(options.artifactRoot, "runtime"); + const actual = fs.existsSync(runtimeRoot) ? fs.readdirSync(runtimeRoot, { withFileTypes: true }) : []; + for (const entry of actual) { + const reference = `runtime/${entry.name}`; + if (!entry.isFile() || !expected.has(reference)) failures.push({ code: "runtime_artifact_unmanifested", message: `${reference} is not a declared regular session artifact`, path: runtimeRoot }); + } + for (const reference of expected) if (!fs.existsSync(path.join(options.artifactRoot, reference))) failures.push({ code: "runtime_artifact_missing", message: `${reference} is missing`, path: runtimeRoot }); +} + +const uniqueFailures = [...new Map(failures.map((failure) => [`${failure.code}:${failure.path}:${failure.message}`, failure])).values()]; +const result = { checkedProfiles: profiles.length, failures: uniqueFailures, ok: uniqueFailures.length === 0, warnings: [] }; +if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +else if (result.ok) process.stdout.write(`ok: ${profiles.length} governed-local component profiles\n`); +else process.stderr.write(`${result.failures.map((failure) => `${failure.code}: ${failure.path}: ${failure.message}`).join("\n")}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/scripts/validate-consumer-conformance.mjs b/scripts/validate-consumer-conformance.mjs new file mode 100644 index 0000000..488c8e6 --- /dev/null +++ b/scripts/validate-consumer-conformance.mjs @@ -0,0 +1,477 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { isDeepStrictEqual } from "node:util"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; +import { + MIGRATION_DIMENSIONS, + consumerConformanceSchemaFindings, + isNormalizedRepositoryPath, + validateConsumerConformanceSemantics, + validateConsumerPageEvidenceSemantics, + validateConsumerPageIntentSemantics, +} from "./consumer-conformance-contract.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; +import { addDateTimeFormat } from "./json-schema-formats.mjs"; +import { + addSchemaFindings, + compilePageEvidenceSchemas, + digest, + gitIdentity, + normalizeReference, + PAGE_EVIDENCE_MANIFEST, + readJsonFile, + resolveContained, +} from "./page-evidence-contract.mjs"; +import { MAX_ARTIFACT_FILE_BYTES } from "./page-artifact-metadata.mjs"; + +const styleGalleryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const schemaPath = path.join(styleGalleryRoot, "consumer-reference", "schema", "consumer-conformance-record.schema.json"); +const pageSchemaRoot = path.join(styleGalleryRoot, "consumer-reference", "schema"); +const pageValidator = path.join(styleGalleryRoot, "scripts", "validate-page-evidence.mjs"); +const runtimeTimeout = Math.min(120_000, Math.max(100, Number.parseInt(process.env.STYLEGALLERY_RUNTIME_TIMEOUT_MS ?? "120000", 10) || 120_000)); + +function finding(code, message, recordPath) { + return { code, message, path: recordPath }; +} + +function parseArguments(argv) { + const options = { artifactRoot: undefined, json: false, priorManifest: undefined, record: undefined, root: process.cwd() }; + const failures = []; + for (let index = 2; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--json") options.json = true; + else if (["--artifact-root", "--prior-manifest", "--record", "--root"].includes(argument)) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) failures.push(finding("argument_value_required", `${argument} requires a value`, "")); + else { + if (argument === "--artifact-root") options.artifactRoot = value; + if (argument === "--prior-manifest") options.priorManifest = value; + if (argument === "--record") options.record = value; + if (argument === "--root") options.root = value; + index += 1; + } + } else failures.push(finding("argument_unknown", `unsupported argument ${argument}`, "")); + } + if (!options.record) failures.push(finding("argument_value_required", "--record requires a normalized JSON path", "")); + return { failures, options }; +} + +function containedRecord(rootArgument, recordArgument, failures) { + const root = path.resolve(process.cwd(), rootArgument); + if (!fs.existsSync(root) || !fs.lstatSync(root).isDirectory() || fs.realpathSync(root) !== root) { + failures.push(finding("consumer_conformance_root_invalid", "consumer root must be a real directory without filesystem redirects", rootArgument)); + return undefined; + } + if (!isNormalizedRepositoryPath(recordArgument, { jsonOnly: true })) { + failures.push(finding("consumer_conformance_record_path_invalid", "--record must be a normalized repository-relative JSON path", recordArgument)); + return undefined; + } + const resolved = path.resolve(root, recordArgument); + const relative = path.relative(root, resolved); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) || !fs.existsSync(resolved)) { + failures.push(finding("consumer_conformance_record_path_invalid", "consumer conformance record must exist inside the consumer root", recordArgument)); + return undefined; + } + let current = root; + for (const segment of relative.split(path.sep).filter(Boolean)) { + current = path.join(current, segment); + if (fs.lstatSync(current).isSymbolicLink()) { + failures.push(finding("consumer_conformance_record_path_invalid", "consumer conformance record must not traverse a symlink", recordArgument)); + return undefined; + } + } + if (!fs.lstatSync(resolved).isFile() || fs.realpathSync(resolved) !== resolved) { + failures.push(finding("consumer_conformance_record_path_invalid", "consumer conformance record must be a contained regular file", recordArgument)); + return undefined; + } + return { recordPath: relative.split(path.sep).join("/"), resolved, root }; +} + +function readStrictJson(file, code, displayPath, failures) { + try { + if (fs.lstatSync(file).size > MAX_ARTIFACT_FILE_BYTES) throw new Error(`file exceeds ${MAX_ARTIFACT_FILE_BYTES} bytes`); + return parseStrictJson(fs.readFileSync(file, "utf8")); + } catch (error) { + failures.push(finding(code, error instanceof Error ? error.message : String(error), displayPath)); + return undefined; + } +} + +function uniqueFindings(findings) { + return [...new Map(findings.map((entry) => [`${entry.code}:${entry.path}:${entry.message}`, entry])).values()]; +} + +function runGit(root, args, encoding = "utf8") { + return spawnSync("git", ["-c", `safe.directory=${path.resolve(root)}`, ...args], { cwd: root, encoding }); +} + +function firstFilesystemRedirect(root) { + const pending = [root]; + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + const stat = fs.lstatSync(target); + if (stat.isSymbolicLink()) return path.relative(root, target).split(path.sep).join("/"); + if (stat.isDirectory()) pending.push(target); + } + } + return undefined; +} + +function executePinnedScenario(root, revision, scenario, expectedResult, failures) { + if (!Array.isArray(scenario?.argv) || scenario.argv.length === 0 || scenario.argv.some((argument) => typeof argument !== "string" || argument.length === 0)) return false; + const [executable, ...args] = scenario.argv; + if (executable !== "node") { + failures.push(finding("runtime_command_executable_invalid", `scenario ${scenario.id ?? ""} must use the governed Node runtime`, scenario.id ?? "")); + return undefined; + } + const entrypoint = args[0]; + if (!isNormalizedRepositoryPath(entrypoint ?? "") || entrypoint.startsWith("-") || !/\.(?:c?js|mjs)$/.test(entrypoint)) { + failures.push(finding("runtime_command_argv_unsafe", `scenario ${scenario.id ?? ""} may not override the governed Node permission boundary`, scenario.id ?? "")); + return undefined; + } + const temporaryRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "stylegallery-consumer-command-"))); + const checkout = path.join(temporaryRoot, "checkout"); + let worktreeAdded = false; + try { + const add = runGit(root, ["worktree", "add", "--detach", checkout, revision]); + if (add.status !== 0) { + failures.push(finding("runtime_command_checkout_failed", `scenario ${scenario.id ?? ""} could not check out the pinned revision`, scenario.id ?? "")); + return false; + } + worktreeAdded = true; + const redirect = firstFilesystemRedirect(checkout); + if (redirect) { + failures.push(finding("runtime_command_checkout_symlink", `scenario ${scenario.id ?? ""} checkout contains filesystem redirect ${redirect}`, redirect)); + return undefined; + } + const resultFile = path.resolve(checkout, scenario.result_artifact); + fs.rmSync(resultFile, { force: true }); + const child = spawnSync(process.execPath, ["--permission", `--allow-fs-read=${checkout}`, `--allow-fs-write=${checkout}`, ...args], { + cwd: checkout, + env: { + CI: "1", + LANG: "C", + LC_ALL: "C", + PATH: process.env.PATH ?? "", + STYLEGALLERY_RESULT_ARTIFACT: scenario.result_artifact, + STYLEGALLERY_RUNTIME_CONTEXT: JSON.stringify(expectedResult), + }, + killSignal: "SIGKILL", + shell: false, + stdio: "ignore", + timeout: runtimeTimeout, + }); + if (child.error || child.status !== 0) { + const outcome = child.error?.code === "ETIMEDOUT" ? "timed out" : child.signal ? `received ${child.signal}` : `exited ${child.status ?? "without a status"}`; + failures.push(finding("runtime_command_failed", `scenario ${scenario.id ?? ""} ${outcome} at the pinned revision`, scenario.id ?? "")); + return undefined; + } + try { + const stat = fs.lstatSync(resultFile); + if (stat.size > MAX_ARTIFACT_FILE_BYTES) throw new Error(`result exceeds ${MAX_ARTIFACT_FILE_BYTES} bytes`); + if (!stat.isFile() || stat.isSymbolicLink() || !fs.realpathSync(resultFile).startsWith(`${fs.realpathSync(checkout)}${path.sep}`)) throw new Error("result is not a contained regular file"); + return parseStrictJson(fs.readFileSync(resultFile, "utf8")); + } catch (error) { + failures.push(finding("runtime_command_result_missing", `scenario ${scenario.id ?? ""} did not produce a valid result artifact: ${error instanceof Error ? error.message : String(error)}`, scenario.result_artifact)); + return undefined; + } + } finally { + if (worktreeAdded) { + const removed = runGit(root, ["worktree", "remove", "--force", "--force", checkout]); + if (removed.status !== 0) { + try { fs.rmSync(checkout, { force: true, recursive: true }); } catch {} + runGit(root, ["worktree", "prune", "--expire", "now"]); + const registered = runGit(root, ["worktree", "list", "--porcelain"]); + if (registered.status !== 0 || registered.stdout.includes(checkout)) failures.push(finding("runtime_command_cleanup_failed", `scenario ${scenario.id ?? ""} worktree cleanup failed`, scenario.id ?? "")); + } + } + fs.rmSync(temporaryRoot, { force: true, recursive: true }); + } +} + +function remoteIdentity(remote) { + const cleaned = remote.trim().replace(/\.git$/, ""); + const scp = cleaned.match(/^[^@]+@[^:]+:(.+)$/); + if (scp) return scp[1]; + try { return new URL(cleaned).pathname.replace(/^\//, ""); } + catch { return undefined; } +} + +function pinnedSourceManifest(root, revision, references, failures) { + if (!Array.isArray(references) || references.length === 0 || new Set(references).size !== references.length) return undefined; + const revisionCheck = runGit(root, ["cat-file", "-e", `${revision}^{commit}`]); + if (revisionCheck.status !== 0) { + failures.push(finding("consumer_revision_unknown", "consumer revision must resolve to a commit in the repository", revision)); + return undefined; + } + const files = []; + for (const reference of [...references].sort()) { + if (!isNormalizedRepositoryPath(reference)) continue; + const tree = runGit(root, ["ls-tree", "-z", revision, "--", reference], "buffer"); + const entry = tree.status === 0 ? tree.stdout.toString("utf8").split("\0").find((line) => line.endsWith(`\t${reference}`)) : undefined; + if (!entry) { + failures.push(finding("consumer_source_missing", "relevant source is missing from the pinned revision", reference)); + continue; + } + const [metadata] = entry.split("\t", 1); + const [mode, type] = metadata.split(" "); + if (mode === "120000") { + failures.push(finding("consumer_source_symlink", "relevant source must not be a symbolic link", reference)); + continue; + } + if (type !== "blob") { + failures.push(finding("consumer_source_type_invalid", "relevant source must be a regular file", reference)); + continue; + } + const blob = runGit(root, ["show", `${revision}:${reference}`], "buffer"); + if (blob.status !== 0) { + failures.push(finding("consumer_source_unreadable", "relevant source could not be read from the pinned revision", reference)); + continue; + } + files.push({ byte_length: blob.stdout.length, path: reference, sha256: digest(blob.stdout) }); + } + return files.length === references.length ? { files, sha256: digest(Buffer.from(JSON.stringify(files))) } : undefined; +} + +function markdownAnchors(bytes) { + const occurrences = new Map(); + return bytes.toString("utf8").split("\n").flatMap((line) => { + const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/)?.[1]; + if (!heading) return []; + const base = heading + .replace(/`([^`]*)`/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .toLowerCase() + .replace(/[^a-z0-9 _-]/g, "") + .trim() + .replace(/\s+/g, "-"); + const occurrence = occurrences.get(base) ?? 0; + occurrences.set(base, occurrence + 1); + return [occurrence === 0 ? `#${base}` : `#${base}-${occurrence}`]; + }); +} + +function validateStyleGallerySources(value, recordPath, failures) { + const mappings = Array.isArray(value?.adoption_mappings) ? value.adoption_mappings : []; + let checked = 0; + for (const [index, mapping] of mappings.entries()) { + const source = mapping?.stylegallery; + if (!source || !/^[a-f0-9]{40}$/.test(source.revision ?? "") || !isNormalizedRepositoryPath(source.path)) continue; + const revision = runGit(styleGalleryRoot, ["cat-file", "-e", `${source.revision}^{commit}`]); + if (revision.status !== 0) { + failures.push(finding("adoption_stylegallery_revision_unknown", `adoption mapping ${index} revision does not resolve in StyleGallery`, recordPath)); + continue; + } + const tree = runGit(styleGalleryRoot, ["ls-tree", "-z", source.revision, "--", source.path], "buffer"); + const entry = tree.status === 0 ? tree.stdout.toString("utf8").split("\0").find((line) => line.endsWith(`\t${source.path}`)) : undefined; + if (!entry) { + failures.push(finding("adoption_stylegallery_path_missing", `adoption mapping ${index} path is missing from its pinned StyleGallery revision`, source.path)); + continue; + } + const [metadata] = entry.split("\t", 1); + const [mode, type] = metadata.split(" "); + if (mode === "120000" || type !== "blob") { + failures.push(finding("adoption_stylegallery_path_invalid", `adoption mapping ${index} path must be a regular StyleGallery file`, source.path)); + continue; + } + const blob = runGit(styleGalleryRoot, ["show", `${source.revision}:${source.path}`], "buffer"); + if (blob.status !== 0) { + failures.push(finding("adoption_stylegallery_path_unreadable", `adoption mapping ${index} path could not be read from StyleGallery`, source.path)); + continue; + } + if (!markdownAnchors(blob.stdout).includes(source.anchor)) { + failures.push(finding("adoption_stylegallery_anchor_missing", `adoption mapping ${index} anchor is missing from its pinned StyleGallery file`, `${source.path}${source.anchor}`)); + continue; + } + checked += 1; + } + return checked; +} + +function validateRuntimeEvidence({ recordPath, root, validateResult, value }, failures) { + const consumer = value?.consumer; + const scenarios = Array.isArray(value?.scenarios) ? value.scenarios : []; + if (!consumer || typeof consumer.repository !== "string" || typeof consumer.revision !== "string" || !Array.isArray(consumer.relevant_sources)) return 0; + + const gitFailures = []; + const identity = gitIdentity(root, gitFailures); + for (const issue of gitFailures) failures.push(finding("consumer_git_invalid", issue.message, issue.path)); + if (identity) { + const remote = runGit(root, ["config", "--get", "remote.origin.url"]); + const repositories = new Set([identity.repository, remote.status === 0 ? remoteIdentity(remote.stdout) : undefined].filter(Boolean)); + if (repositories.size === 0) failures.push(finding("consumer_repository_unverifiable", "consumer repository requires a verifiable Git remote identity", recordPath)); + else if (!repositories.has(consumer.repository)) failures.push(finding("consumer_repository_mismatch", "consumer repository differs from the Git repository identity", recordPath)); + } + + const source = pinnedSourceManifest(root, consumer.revision, consumer.relevant_sources, failures); + if (source) { + for (const scenario of scenarios) { + if (scenario?.source_digest !== source.sha256) failures.push(finding("runtime_source_digest_mismatch", `scenario ${scenario?.id ?? ""} source digest differs from the pinned relevant sources`, recordPath)); + } + } + + let checked = 0; + let executed = 0; + for (const scenario of scenarios) { + if (!isNormalizedRepositoryPath(scenario?.result_artifact, { jsonOnly: true })) continue; + const result = readJsonFile({ prefix: "runtime_result_artifact", reference: scenario.result_artifact, root }, failures); + if (!result) continue; + checked += 1; + if (scenario.evidence_method === "browser" && value.page_evidence?.status === "applicable") continue; + if (!validateResult(result.value)) { + for (const error of validateResult.errors ?? []) failures.push(finding("runtime_result_artifact_schema_invalid", `${error.instancePath || "/"} ${error.message}`, scenario.result_artifact)); + continue; + } + const expected = { + argv: scenario.argv, + evidence_method: scenario.evidence_method, + exit_code: scenario.exit_code, + repository: consumer.repository, + revision: consumer.revision, + run_id: scenario.run_id, + scenario_id: scenario.id, + session_id: scenario.session_id, + source_digest: scenario.source_digest, + }; + if (Object.entries(expected).some(([key, expectedValue]) => Array.isArray(expectedValue) + ? !Array.isArray(result.value[key]) || expectedValue.length !== result.value[key].length || expectedValue.some((entry, index) => result.value[key][index] !== entry) + : result.value[key] !== expectedValue)) { + failures.push(finding("runtime_result_artifact_mismatch", `scenario ${scenario.id ?? ""} result artifact identity differs from the conformance record`, scenario.result_artifact)); + } + const executedResult = identity && source ? executePinnedScenario(root, consumer.revision, scenario, expected, failures) : undefined; + if (executedResult !== undefined) { + executed += 1; + if (!validateResult(executedResult)) { + for (const error of validateResult.errors ?? []) failures.push(finding("runtime_command_result_invalid", `${error.instancePath || "/"} ${error.message}`, scenario.result_artifact)); + } else if (!isDeepStrictEqual(executedResult, result.value)) { + failures.push(finding("runtime_result_artifact_execution_mismatch", `scenario ${scenario.id ?? ""} stored result differs from the result produced at the pinned revision`, scenario.result_artifact)); + } + } + } + return { checked, executed }; +} + +function runPageValidator({ artifactRoot, manifest, priorManifest, root }, failures) { + const args = [pageValidator, "--root", root, "--artifact-root", artifactRoot, "--manifest", manifest]; + if (priorManifest) args.push("--prior-manifest", priorManifest); + args.push("--json"); + const child = spawnSync(process.execPath, args, { cwd: styleGalleryRoot, encoding: "utf8" }); + let report; + try { + report = parseStrictJson(child.stdout); + } catch (error) { + failures.push(finding("page_evidence_validator_output_invalid", error instanceof Error ? error.message : String(error), manifest)); + return undefined; + } + if (Array.isArray(report?.failures)) failures.push(...report.failures); + if (child.status !== 0 && report?.failures?.length === 0) failures.push(finding("page_evidence_validator_failed", "page evidence validator exited without a finding", manifest)); + return report; +} + +function validatePageEvidence({ artifactRootArgument, priorManifestArgument, recordPath, root, value }, failures) { + if (value?.page_evidence?.status !== "applicable") return undefined; + const intentFindings = validateConsumerPageIntentSemantics(value, recordPath); + failures.push(...intentFindings); + if (intentFindings.some((entry) => entry.code === "page_evidence_runtime_scenario_required")) return undefined; + if (!artifactRootArgument) { + failures.push(finding("page_evidence_artifact_root_required", "applicable page evidence requires --artifact-root", value.page_evidence.manifest ?? "")); + return undefined; + } + const artifactReference = normalizeReference(root, artifactRootArgument); + if (!artifactReference) { + failures.push(finding("page_evidence_artifact_escape", "artifact root must be a normalized path inside the consumer root", artifactRootArgument)); + return undefined; + } + const artifact = resolveContained({ expectedType: "directory", prefix: "page_evidence_artifact", reference: artifactReference, root }, failures); + const expectedManifest = `${artifactReference}/${PAGE_EVIDENCE_MANIFEST}`; + if (value.page_evidence.manifest !== expectedManifest) { + failures.push(finding("page_evidence_manifest_path_mismatch", `page evidence manifest must equal ${expectedManifest}`, value.page_evidence.manifest)); + return undefined; + } + const manifest = readJsonFile({ prefix: "page_evidence_manifest", reference: expectedManifest, root }, failures); + if (!artifact || !manifest) return undefined; + let schemas; + try { + schemas = compilePageEvidenceSchemas(pageSchemaRoot); + } catch (error) { + failures.push(finding("page_evidence_schema_invalid", error instanceof Error ? error.message : String(error), "consumer-reference/schema")); + return undefined; + } + if (!addSchemaFindings(schemas.manifest, manifest.value, manifest.reference, "page_evidence_manifest_schema_invalid", failures)) return undefined; + failures.push(...validateConsumerPageEvidenceSemantics(value, manifest.value, recordPath)); + + let priorManifest; + if (priorManifestArgument) { + const reference = normalizeReference(root, priorManifestArgument); + const prior = reference ? resolveContained({ expectedType: "file", prefix: "page_evidence_reuse_manifest", reference, root }, failures) : undefined; + if (!reference) failures.push(finding("page_evidence_reuse_manifest_escape", "prior manifest must be a normalized path inside the consumer root", priorManifestArgument)); + priorManifest = prior?.file; + } + if (priorManifestArgument && !priorManifest) return undefined; + return runPageValidator({ artifactRoot: artifact.file, manifest: manifest.file, priorManifest, root }, failures); +} + +const { failures: argumentFailures, options } = parseArguments(process.argv); +const failures = [...argumentFailures]; +const record = options.record ? containedRecord(options.root, options.record, failures) : undefined; +const schema = readStrictJson(schemaPath, "consumer_conformance_schema_invalid", "consumer-reference/schema/consumer-conformance-record.schema.json", failures); +const value = record ? readStrictJson(record.resolved, "consumer_conformance_json_invalid", record.recordPath, failures) : undefined; +let checkedRuntimeArtifacts = 0; +let checkedRuntimeCommands = 0; +let checkedStyleGallerySources = 0; +let recordSchemaValid = false; + +if (schema && value !== undefined) { + try { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addDateTimeFormat(ajv); + ajv.addSchema(schema); + const validate = ajv.getSchema(schema.$id); + const validateResult = ajv.compile({ $ref: `${schema.$id}#/$defs/runtimeResult` }); + const schemaValid = validate(value); + recordSchemaValid = schemaValid; + if (!schemaValid) failures.push(...consumerConformanceSchemaFindings(validate.errors, record.recordPath)); + else { + failures.push(...validateConsumerConformanceSemantics(value, record.recordPath)); + checkedStyleGallerySources = validateStyleGallerySources(value, record.recordPath, failures); + const runtimeEvidence = validateRuntimeEvidence({ recordPath: record.recordPath, root: record.root, validateResult, value }, failures); + checkedRuntimeArtifacts = runtimeEvidence.checked; + checkedRuntimeCommands = runtimeEvidence.executed; + } + } catch (error) { + failures.push(finding("consumer_conformance_schema_invalid", error instanceof Error ? error.message : String(error), "consumer-reference/schema/consumer-conformance-record.schema.json")); + } +} + +const pageReport = record && value !== undefined && recordSchemaValid + ? validatePageEvidence({ artifactRootArgument: options.artifactRoot, priorManifestArgument: options.priorManifest, recordPath: record.recordPath, root: record.root, value }, failures) + : undefined; + +const uniqueFailures = uniqueFindings(failures); +const result = { + checkedDimensions: value && typeof value.migration_dimensions === "object" && value.migration_dimensions !== null + ? MIGRATION_DIMENSIONS.filter((name) => Object.hasOwn(value.migration_dimensions, name)).length + : 0, + checkedMappings: Array.isArray(value?.adoption_mappings) ? value.adoption_mappings.length : 0, + checkedRuntimeArtifacts, + checkedRuntimeCommands, + checkedScenarios: Array.isArray(value?.scenarios) ? value.scenarios.length : 0, + checkedStyleGallerySources, + checkedPageArtifacts: pageReport?.artifactCount ?? 0, + checkedPageScenarios: pageReport?.scenarioCount ?? 0, + failures: uniqueFailures, + ok: uniqueFailures.length === 0, + pageSessionId: pageReport?.sessionId ?? null, + record: record?.recordPath ?? options.record ?? null, +}; +if (options.json) console.log(JSON.stringify(result, null, 2)); +else if (result.ok) console.log(`ok: ${result.checkedDimensions} migration dimensions, ${result.checkedMappings} adoption mappings`); +else console.error(result.failures.map((entry) => `${entry.code}: ${entry.path}: ${entry.message}`).join("\n")); +process.exitCode = result.ok ? 0 : 1; diff --git a/scripts/validate-consumer-reference.mjs b/scripts/validate-consumer-reference.mjs index 9cdc328..8813db1 100644 --- a/scripts/validate-consumer-reference.mjs +++ b/scripts/validate-consumer-reference.mjs @@ -2,23 +2,48 @@ import fs from "node:fs"; import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; import { isPlainObject, validateItemSchema } from "./consumer-reference-schema.mjs"; +import { canonicalGovernedProfilePaths } from "./governed-profile-registry.mjs"; +import { validateReferenceProfile, validateReferenceProfileSet } from "./reference-profile-contract.mjs"; const root = process.cwd(); -const repositoryRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const conformanceValidator = path.join(repositoryRoot, "scripts", "validate-consumer-conformance.mjs"); const schema = JSON.parse(fs.readFileSync(path.join(repositoryRoot, "consumer-reference", "schema", "item.schema.json"), "utf8")); +const validateFullItemSchema = new Ajv2020({ allErrors: true, strict: false }).compile(schema); const failures = []; const warnings = []; +const canonicalProfilePaths = canonicalGovernedProfilePaths; const defaultItems = [ "consumer-reference/fixtures/valid-experimental.json", "consumer-reference/fixtures/valid-deprecated.json", + ...canonicalProfilePaths, ]; -const reverseMarkers = ["consumerreference", "designengineeringreferenceprofiles", "profilejson", "tokensdtcgjson"]; +const reverseMarkers = ["consumerreference", "designengineeringreferenceprofiles", "profilejson", "tokensdtcgjson", "relatedfixturesetid"]; +const profileSummaries = []; +let checkedMigrationRecords = 0; function addFailure(code, relative, message) { failures.push({ code, message, path: relative }); } +function canonicalProfileIdentity(selected) { + if (selected.startsWith("//") + || selected.startsWith("\\\\") + || path.posix.isAbsolute(selected) + || path.win32.isAbsolute(selected) + || /^[A-Za-z][A-Za-z\d+.-]*:/.test(selected) + || selected.includes("\\") + || selected.includes("?") + || selected.includes("#")) return null; + const normalized = path.posix.normalize(selected); + if (normalized === ".." || normalized.startsWith("../") || !isInside(root, path.resolve(root, normalized))) return null; + return canonicalProfilePaths.includes(normalized) ? normalized : null; +} + function isInside(base, target) { const relative = path.relative(base, target); return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); @@ -35,7 +60,7 @@ function parseArguments() { } if (argument === "--item" || argument === "--profile") { const value = process.argv[index + 1]; - if (!value) { + if (!value || value.startsWith("--")) { addFailure("argument_value_required", "", `${argument} requires a repository-relative JSON path`); } else { items.push(value); @@ -45,68 +70,97 @@ function parseArguments() { } addFailure("argument_unknown", "", `unsupported argument ${argument}`); } - return { items: items.length > 0 ? items : defaultItems, json }; + const selected = items.length > 0 ? items : defaultItems; + const canonicalized = selected.map((item) => canonicalProfileIdentity(item) ?? item); + const requiresCanonicalPair = canonicalized.some((item) => canonicalProfilePaths.includes(item)); + return { items: requiresCanonicalPair ? [...new Set([...canonicalized, ...canonicalProfilePaths])] : canonicalized, json }; } -function validateRecordPath(record, itemPath) { +function validateRecordPath(record, itemPath, prefix = "record") { + const reject = (suffix, message) => addFailure(`${prefix}_${suffix}`, itemPath, message); if (typeof record !== "string" || record.length === 0) { - addFailure("record_required", itemPath, "declared handoff requires record"); - return; + reject("required", "declared handoff requires record"); + return undefined; } if (record.startsWith("//") || record.startsWith("\\\\")) { - addFailure("record_network_path", itemPath, "record must not use a network path"); - return; + reject("network_path", "record must not use a network path"); + return undefined; } if (path.posix.isAbsolute(record) || path.win32.isAbsolute(record)) { - addFailure("record_absolute", itemPath, "record must be repository-relative"); - return; + reject("absolute", "record must be repository-relative"); + return undefined; } if (/^[A-Za-z][A-Za-z\d+.-]*:/.test(record)) { - addFailure("record_uri_scheme", itemPath, "record must not use a URI scheme"); - return; + reject("uri_scheme", "record must not use a URI scheme"); + return undefined; } if (record.split(/[\\/]/).includes("..")) { - addFailure("record_parent_segment", itemPath, "record must not contain a parent segment"); - return; + reject("parent_segment", "record must not contain a parent segment"); + return undefined; } if (record.includes("\\") || record.includes("?") || record.includes("#") || path.posix.normalize(record) !== record || record.startsWith("./")) { - addFailure("record_not_normalized", itemPath, "record must be a normalized POSIX repository path"); - return; + reject("not_normalized", "record must be a normalized POSIX repository path"); + return undefined; } if (!record.endsWith(".json")) { - addFailure("record_not_json", itemPath, "record must target a JSON file"); - return; + reject("not_json", "record must target a JSON file"); + return undefined; } const target = path.resolve(root, record); if (!isInside(root, target)) { - addFailure("record_escape", itemPath, "record resolves outside the repository root"); - return; + reject("escape", "record resolves outside the repository root"); + return undefined; } if (!fs.existsSync(target) || !fs.statSync(target).isFile()) { - addFailure("record_unresolved", itemPath, `record does not resolve: ${record}`); - return; + reject("unresolved", `record does not resolve: ${record}`); + return undefined; } const realRoot = fs.realpathSync(root); const realTarget = fs.realpathSync(target); if (!isInside(realRoot, realTarget)) { - addFailure("record_symlink_escape", itemPath, "record resolves through a symlink outside the repository root"); - return; + reject("symlink_escape", "record resolves through a symlink outside the repository root"); + return undefined; } if (realTarget !== path.join(realRoot, record)) { - addFailure("record_redirect", itemPath, "record must not resolve through a filesystem redirect"); - return; + reject("redirect", "record must not resolve through a filesystem redirect"); + return undefined; } try { - JSON.parse(fs.readFileSync(realTarget, "utf8")); + return { record, value: JSON.parse(fs.readFileSync(realTarget, "utf8")) }; } catch (error) { if (error instanceof SyntaxError) { - addFailure("record_invalid_json", itemPath, `record is not valid JSON: ${record}`); - return; + reject("invalid_json", `record is not valid JSON: ${record}`); + return undefined; } throw error; } } +function runMigrationConformance(packet, relative) { + const args = [conformanceValidator, "--root", root, "--record", packet.record]; + const manifest = packet.value?.page_evidence?.status === "applicable" ? packet.value.page_evidence.manifest : undefined; + if (typeof manifest === "string") args.push("--artifact-root", path.posix.dirname(manifest)); + args.push("--json"); + checkedMigrationRecords += 1; + const child = spawnSync(process.execPath, args, { cwd: repositoryRoot, encoding: "utf8" }); + let report; + try { report = JSON.parse(child.stdout); } + catch (error) { + addFailure("migration_conformance_validator_output_invalid", relative, error instanceof Error ? error.message : String(error)); + return; + } + const childFailures = Array.isArray(report.failures) ? report.failures : []; + for (const failure of childFailures) { + addFailure( + typeof failure?.code === "string" ? failure.code : "migration_conformance_validator_output_invalid", + typeof failure?.path === "string" ? failure.path : relative, + typeof failure?.message === "string" ? failure.message : "consumer conformance validator returned a malformed finding", + ); + } + if (child.status !== 0 && childFailures.length === 0) addFailure("migration_conformance_validator_failed", relative, "consumer conformance validator exited without a finding"); + if (child.status === 0 && report.ok !== true) addFailure("migration_conformance_validator_output_invalid", relative, "consumer conformance validator exited zero without ok:true"); +} + function validateHandoff(item, relative) { if (isPlainObject(item.handoff) && item.handoff.status === "declared") validateRecordPath(item.handoff.record, relative); } @@ -137,9 +191,17 @@ function validateItem(relative) { } throw error; } + if (!validateFullItemSchema(item)) { + for (const error of validateFullItemSchema.errors ?? []) addFailure("item_schema_invalid", relative, `${error.instancePath || "/"} ${error.message}`); + } for (const finding of validateItemSchema(item, schema)) addFailure(finding.code, relative, finding.message); if (!isPlainObject(item)) return; validateHandoff(item, relative); + if (/^design-engineering\/reference-profiles\/governed-local\/[^/]+\/profile\.json$/.test(relative)) { + const result = validateReferenceProfile({ item, relative, root }); + for (const finding of result.failures) addFailure(finding.code, finding.path, finding.message); + if (result.summary) profileSummaries.push(result.summary); + } } function walkFiles(relative) { @@ -162,6 +224,13 @@ function rejectReverseImports() { } } +function rejectExternalAdaptationRecords() { + const directory = "design-engineering/reference-profiles/external-adaptation"; + for (const relative of walkFiles(directory).filter((file) => file.endsWith(".json"))) { + addFailure("external_adaptation_record_forbidden", relative, "external adaptation has documentation and synthetic validator coverage only"); + } +} + function isSentence(reason) { const variants = schema.properties.handoff.oneOf; const definition = variants.find((candidate) => candidate.properties.status.const === "not_applicable").properties.reason; @@ -185,6 +254,20 @@ function validateHandoffCoverage() { } else if (!/Consumer reference record:\s*\S+\.json/i.test(window)) { addFailure("handoff_consumer_record_required", relative, `declared handoff at line ${index + 1} requires one JSON record`); } + const migrationDeclarations = [...window.matchAll(/^Consumer migration conformance:\s*(\S+)\s*$/gim)]; + const migrationRecords = [...window.matchAll(/^Consumer migration conformance record:\s*([^\n]+)\s*$/gim)]; + if (migrationDeclarations.length === 0 && migrationRecords.length > 0) { + addFailure("migration_conformance_declaration_required", relative, `migration record at line ${index + 1} requires an exact declared conformance field`); + } else if (migrationDeclarations.length > 0) { + if (migrationDeclarations.length !== 1 || migrationDeclarations[0][1].toLowerCase() !== "declared") { + addFailure("migration_conformance_status_invalid", relative, `migration handoff at line ${index + 1} accepts exactly one declared status`); + } else if (migrationRecords.length !== 1) { + addFailure("migration_conformance_record_required", relative, `declared migration handoff at line ${index + 1} requires exactly one JSON record`); + } else { + const packet = validateRecordPath(migrationRecords[0][1].trim(), relative, "migration_conformance_record"); + if (packet) runMigrationConformance(packet, relative); + } + } } } return checked; @@ -193,11 +276,13 @@ function validateHandoffCoverage() { const options = parseArguments(); for (const item of options.items) validateItem(item); rejectReverseImports(); +rejectExternalAdaptationRecords(); +for (const finding of validateReferenceProfileSet(profileSummaries)) addFailure(finding.code, finding.path, finding.message); const checkedHandoffs = validateHandoffCoverage(); const uniqueFailures = [...new Map(failures.map((failure) => [`${failure.code}:${failure.path}:${failure.message}`, failure])).values()]; -const result = { checkedHandoffs, checkedItems: options.items.length, failures: uniqueFailures, ok: uniqueFailures.length === 0, warnings }; +const result = { checkedHandoffs, checkedItems: options.items.length, checkedMigrationRecords, failures: uniqueFailures, ok: uniqueFailures.length === 0, profiles: profileSummaries, warnings }; if (options.json) console.log(JSON.stringify(result, null, 2)); else if (result.ok) console.log(`ok: ${result.checkedItems} consumer reference items`); else console.error(result.failures.map((failure) => `${failure.code}: ${failure.path}: ${failure.message}`).join("\n")); -process.exit(result.ok ? 0 : 1); +process.exitCode = result.ok ? 0 : 1; diff --git a/scripts/validate-domains.mjs b/scripts/validate-domains.mjs index 9d91490..c926318 100644 --- a/scripts/validate-domains.mjs +++ b/scripts/validate-domains.mjs @@ -2,15 +2,19 @@ import fs from "node:fs"; import path from "node:path"; -import { isOmoDependency, markdownLinkDestinations, structuralMarkdown } from "./markdown-structure.mjs"; +import { fileURLToPath } from "node:url"; +import { collectDomainBoundaryFailures } from "./domain-document-boundaries.mjs"; +import { createDomainValidationChecks } from "./domain-validation-checks.mjs"; +import { structuralMarkdown } from "./markdown-structure.mjs"; -const args = new Set(process.argv.slice(2)); -const json = args.has("--json"); -const root = process.cwd(); -const failures = []; const repository = "https://github.com/emilkowalski/skills"; const revision = "220e8607c90b17337d210125777b7b695f26c221"; const revisionPattern = /^[0-9a-f]{40}$/; +const referenceDocuments = [ + "design-engineering/reference-profiles/index.md", + "design-engineering/reference-profiles/governed-local/index.md", + "design-engineering/reference-profiles/external-adaptation/index.md", +]; const requiredCrossDomainStrings = [ { relative: "guides/vocabulary.md", @@ -44,40 +48,47 @@ const requiredCrossDomainStrings = [ }, ]; -const domains = [ +export const canonicalDomains = [ { slug: "layout", label: "Layout", leaves: [] }, { slug: "motion", label: "Motion", leaves: [ - ["motion/vocabulary.md", "skills/animation-vocabulary/SKILL.md"], - ["motion/review-workflow.md", "skills/review-animations/SKILL.md"], - ["motion/practice-reference.md", "skills/review-animations/STANDARDS.md"], + { path: "motion/vocabulary.md", provenance: "external", sourcePath: "skills/animation-vocabulary/SKILL.md" }, + { path: "motion/review-workflow.md", provenance: "external", sourcePath: "skills/review-animations/SKILL.md" }, + { path: "motion/practice-reference.md", provenance: "external", sourcePath: "skills/review-animations/STANDARDS.md" }, ], }, - { slug: "design-engineering", label: "Design Engineering", leaves: [["design-engineering/interface-craft.md", "skills/emil-design-eng/SKILL.md"]] }, { - slug: "game-ui", - label: "Game UI", + slug: "design-engineering", + label: "Design Engineering", leaves: [ - ["game-ui/classification.md"], - ["game-ui/screen-hierarchy.md"], - ["game-ui/reference-record.md"], - ["game-ui/unity/architecture.md", "README.md"], - ["game-ui/unity/ui-systems.md"], - ["game-ui/unity/cli-loop.md", "README.md"], - ["game-ui/unity/repository-map.md"], - ["game-ui/unity/org-wiki.md"], + { path: "design-engineering/interface-craft.md", provenance: "external", sourcePath: "skills/emil-design-eng/SKILL.md" }, + { path: "design-engineering/consumer-migration-readiness.md", provenance: "local" }, ], + referenceDocuments, }, { - slug: "platform-guides", - label: "Platform Guides", + slug: "game-ui", + label: "Game UI", leaves: [ - ["platform-guides/apple-interaction.md", "skills/apple-design/SKILL.md"], + { path: "game-ui/classification.md", provenance: "repository" }, + { path: "game-ui/screen-hierarchy.md", provenance: "repository" }, + { path: "game-ui/reference-record.md", provenance: "repository" }, + { path: "game-ui/unity/architecture.md", provenance: "external", sourcePath: "README.md" }, + { path: "game-ui/unity/ui-systems.md", provenance: "repository" }, + { path: "game-ui/unity/cli-loop.md", provenance: "external", sourcePath: "README.md" }, + { path: "game-ui/unity/repository-map.md", provenance: "repository" }, + { path: "game-ui/unity/org-wiki.md", provenance: "repository" }, ], }, + { slug: "platform-guides", label: "Platform Guides", leaves: [{ path: "platform-guides/apple-interaction.md", provenance: "external", sourcePath: "skills/apple-design/SKILL.md" }] }, ]; +export const domainRegistry = canonicalDomains; + +let domains = canonicalDomains; +let root = process.cwd(); +let failures = []; const sourceOverrides = { "game-ui/unity/architecture.md": { @@ -110,32 +121,6 @@ function read(relative) { return fs.readFileSync(target, "utf8"); } -function toRepositoryPath(value) { - return value.split(path.sep).join("/"); -} - -function parseFrontmatter(relative, content) { - const match = content.match(/^---\n([\s\S]*?)\n---\n/); - if (!match) { - failures.push(`${relative}: missing frontmatter`); - return {}; - } - const metadata = {}; - for (const rawLine of match[1].split("\n")) { - if (!rawLine.trim()) continue; - const separator = rawLine.indexOf(":"); - if (separator === -1) { - failures.push(`${relative}: malformed frontmatter line`); - continue; - } - const key = rawLine.slice(0, separator).trim(); - const value = rawLine.slice(separator + 1).trim().replace(/^["']|["']$/g, ""); - if (Object.hasOwn(metadata, key)) failures.push(`${relative}: duplicate frontmatter key ${key}`); - metadata[key] = value; - } - return metadata; -} - function requireRootRoutes() { for (const relative of ["README.md", "index.md"]) { const content = structuralMarkdown(read(relative)); @@ -183,7 +168,7 @@ function checkManifest() { if (!pageRow || pageRow[1] !== expectedManualHub) valid = false; if (domain.leaves.length > 0 && pageRow) { const declaredLeaves = [...pageRow[2].matchAll(/`([^`]+\.md)`/g)].map((match) => match[1]).sort(); - const expectedLeaves = domain.leaves.map(([leaf]) => leaf).sort(); + const expectedLeaves = [...domain.leaves.map((leaf) => leaf.path), ...(domain.referenceDocuments ?? [])].sort(); if (JSON.stringify(declaredLeaves) !== JSON.stringify(expectedLeaves)) valid = false; } } @@ -193,112 +178,60 @@ function checkManifest() { } } -function checkIndex(domain) { - const relative = `${domain.slug}/index.md`; - const content = structuralMarkdown(read(relative)); - if (!content) return; - if (!content.includes("## Scope Boundary")) failures.push(`${relative}: missing Scope Boundary section`); - if (!/^In scope:\s*\S/m.test(content)) failures.push(`${relative}: missing In scope declaration`); - if (!/^Out of scope:\s*\S/m.test(content)) failures.push(`${relative}: missing Out of scope declaration`); - if (!/^Parent: \[[^\]]+\]\([^)]+\)/m.test(content)) failures.push(`${relative}: missing Parent navigation link`); - if (!/^Next: \[[^\]]+\]\([^)]+\)/m.test(content)) failures.push(`${relative}: missing Next navigation link`); - for (const [leaf] of domain.leaves) { - const target = path.posix.relative(domain.slug, leaf); - if (!content.match(new RegExp(`\\[[^\\]]+\\]\\(${target.replaceAll(".", "\\.")}\\)`))) { - failures.push(`${relative}: missing leaf route ${target}`); - } - } +function boundaryRegistry(registry) { + return registry.map((domain) => ({ + ...domain, + leaves: domain.leaves.map((leaf) => [leaf.path, leaf.sourcePath]), + })); } -function checkLeaf(domain, relative, expectedSourcePath, titles) { - const content = read(relative); - if (!content) return; - const metadata = parseFrontmatter(relative, content); - const requiredFields = ["type", "title", "description", "domain", "lifecycle"]; - if (expectedSourcePath) requiredFields.push("source_repository", "source_path", "source_revision"); - for (const field of requiredFields) { - if (!metadata[field]) failures.push(`${relative}: missing ${field}`); - } - const knownDomain = domains.some((candidate) => candidate.slug === metadata.domain); - if (metadata.domain && !knownDomain) failures.push(`${relative}: unknown domain ${metadata.domain}`); - else if (metadata.domain && metadata.domain !== domain.slug) failures.push(`${relative}: domain ${metadata.domain} does not match ${domain.slug}`); - if (metadata.lifecycle && metadata.lifecycle !== "experimental") failures.push(`${relative}: domain leaf lifecycle must be experimental`); - if (expectedSourcePath) { - const expectedSource = sourceOverrides[relative] ?? { repository, revision }; - if (metadata.source_repository && metadata.source_repository !== expectedSource.repository) failures.push(`${relative}: unexpected source_repository ${metadata.source_repository}`); - if (metadata.source_path && metadata.source_path !== expectedSourcePath) failures.push(`${relative}: unexpected source_path ${metadata.source_path}`); - if (metadata.source_revision && !revisionPattern.test(metadata.source_revision)) { - failures.push(`${relative}: source_revision must be a full 40-character lowercase Git SHA`); - } else if (metadata.source_revision && metadata.source_revision !== expectedSource.revision) { - failures.push(`${relative}: unexpected source_revision ${metadata.source_revision}`); - } - } else { - for (const field of ["source_repository", "source_path", "source_revision"]) { - if (Object.hasOwn(metadata, field)) failures.push(`${relative}: locally authored leaf must omit ${field}`); - } - } - if (metadata.title) { - if (titles.has(metadata.title)) failures.push(`${relative}: duplicate title ${metadata.title}`); - titles.add(metadata.title); - } - const body = structuralMarkdown(content); - for (const section of requiredLeafSections) { - if (!body.includes(`## ${section}`)) failures.push(`${relative}: missing ${section} section`); - } - if (!/^Parent: \[[^\]]+\]\([^)]+\)/m.test(body)) failures.push(`${relative}: missing Parent navigation link`); - if (!/^Next: \[[^\]]+\]\([^)]+\)/m.test(body)) failures.push(`${relative}: missing Next navigation link`); - if (markdownLinkDestinations(content).some(isOmoDependency)) failures.push(`${relative}: tracked document must not depend on .omo`); - if (metadata.lifecycle === "experimental" && /canonical universal policy/i.test(body)) { - failures.push(`${relative}: experimental document claims canonical authority`); - } -} - -function rejectUndeclaredDomainDocuments() { +export function validateDomains({ root: nextRoot = process.cwd(), domains: nextDomains = canonicalDomains } = {}) { + const previousRoot = root; + const previousDomains = domains; + const previousFailures = failures; + root = nextRoot; + domains = nextDomains; + failures = []; + const { checkIndex, checkLeaf, checkReferenceDocuments, checkPromotionBoundary } = createDomainValidationChecks({ + domains, + failures, + read, + referenceDocuments, + repository, + revision, + revisionPattern, + requiredLeafSections, + sourceOverrides, + }); + checkManifest(); + read("quality/claim-records/stylegallery-multidomain-scope.md"); + requireRootRoutes(); + requireCrossDomainConsistency(); + const titles = new Set(); + let checkedLeaves = 0; for (const domain of domains) { - const declared = new Set([`${domain.slug}/index.md`, ...domain.leaves.map(([leaf]) => leaf)]); - for (const absolute of walkMarkdown(path.join(root, domain.slug))) { - const relative = toRepositoryPath(path.relative(root, absolute)); - if (!declared.has(relative)) failures.push(`${relative}: undeclared governed domain document`); + checkIndex(domain); + for (const leaf of domain.leaves) { + checkedLeaves += 1; + checkLeaf(domain, leaf, titles); } } -} - -function walkMarkdown(dir) { - if (!fs.existsSync(dir)) return []; - return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - if ([".git", ".omo", "node_modules"].includes(entry.name)) return []; - const target = path.join(dir, entry.name); - if (entry.isDirectory()) return walkMarkdown(target); - return entry.isFile() && entry.name.endsWith(".md") ? [target] : []; - }); -} + failures.push(...collectDomainBoundaryFailures(root, boundaryRegistry(domains))); + checkReferenceDocuments(); + checkPromotionBoundary(); -function rejectOmoDependencies() { - for (const absolute of walkMarkdown(root)) { - const relative = toRepositoryPath(path.relative(root, absolute)); - const content = fs.readFileSync(absolute, "utf8"); - if (markdownLinkDestinations(content).some(isOmoDependency)) failures.push(`${relative}: tracked document must not depend on .omo`); - } + const result = { ok: failures.length === 0, checkedDomains: domains.length, checkedLeaves, failures: [...new Set(failures)] }; + root = previousRoot; + domains = previousDomains; + failures = previousFailures; + return result; } -checkManifest(); -read("quality/claim-records/stylegallery-multidomain-scope.md"); -requireRootRoutes(); -requireCrossDomainConsistency(); -const titles = new Set(); -let checkedLeaves = 0; -for (const domain of domains) { - checkIndex(domain); - for (const [relative, sourcePath] of domain.leaves) { - checkedLeaves += 1; - checkLeaf(domain, relative, sourcePath, titles); - } +const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); +if (isMainModule) { + const result = validateDomains(); + if (process.argv.includes("--json")) console.log(JSON.stringify(result, null, 2)); + else if (result.ok) console.log(`ok: ${result.checkedDomains} domains, ${result.checkedLeaves} governed leaves`); + else console.error(result.failures.join("\n")); + process.exitCode = result.ok ? 0 : 1; } -rejectUndeclaredDomainDocuments(); -rejectOmoDependencies(); - -const result = { ok: failures.length === 0, checkedDomains: domains.length, checkedLeaves, failures: [...new Set(failures)] }; -if (json) console.log(JSON.stringify(result, null, 2)); -else if (result.ok) console.log(`ok: ${result.checkedDomains} domains, ${result.checkedLeaves} governed leaves`); -else console.error(result.failures.join("\n")); -process.exit(result.ok ? 0 : 1); diff --git a/scripts/validate-governance.mjs b/scripts/validate-governance.mjs index 960cad9..a77a9c8 100644 --- a/scripts/validate-governance.mjs +++ b/scripts/validate-governance.mjs @@ -2,39 +2,17 @@ import fs from "node:fs"; import path from "node:path"; - -const args = new Set(process.argv.slice(2)); -const json = args.has("--json"); +import { componentStateWorkflowFailures } from "./component-state-workflow-contract.mjs"; +import { consumerEvidenceGovernanceFailures } from "./consumer-evidence-governance-contract.mjs"; +import { referenceOwnershipFailures } from "./governance-matrix-contract.mjs"; +import { immutableActionPins, requiredCodeowners, sentinelProvenanceClauses } from "./governance-policy-contract.mjs"; +import { promotionGovernanceFailures } from "./promotion-governance-contract.mjs"; +import { workflowActionFailures } from "./workflow-action-contract.mjs"; + +const json = new Set(process.argv.slice(2)).has("--json"); const root = process.cwd(); const failures = []; const warnings = []; -const requiredCodeowners = [ - "* @changeroa", - "/GOVERNANCE.md @changeroa", - "/README.md @changeroa", - "/index.md @changeroa", - "/AGENTS.md @changeroa", - "/DOMAINS.md @changeroa", - "/layout/ @changeroa", - "/motion/ @changeroa", - "/design-engineering/ @changeroa", - "/platform-guides/ @changeroa", - "/consumer-reference/ @changeroa", - "/GUIDE.md @changeroa", - "/guides/ @changeroa", - "/recipes/ @changeroa", - "/quality/ @changeroa", - "/scripts/pattern-data.mjs @changeroa", - "/scripts/generate-patterns.mjs @changeroa", - "/patterns/ @changeroa", - "/CATALOG.md @changeroa", - "/.github/workflows/validate.yml @changeroa", - "/.github/ @changeroa", - "/scripts/ @changeroa", - "/scripts/validate-*.mjs @changeroa", - "/scripts/test-validate-*.mjs @changeroa", -]; - function read(relative) { const target = path.join(root, relative); if (!fs.existsSync(target)) { @@ -74,6 +52,14 @@ function requireGovernanceMatrix() { "Layout recipes", "Quality gates and evidence", "Consumer reference contract", + "Governed local reference profiles", + "Component-state evidence matrices", + "Consumer migration conformance", + "Consumer page-evidence lifecycle", + "Explicit evidence freshness schedule", + "Deterministic consumer browser conformance", + "Shared promotion policy", + "Proposed Chromium sentinel", "Domain manifest and scope decision", "Layout domain hub", "Motion domain guidance", @@ -92,6 +78,17 @@ function requireGovernanceMatrix() { requireIncludes("GOVERNANCE.md", "`scripts/validate-patterns.mjs`, `scripts/validate-catalog.mjs`, `scripts/validate-governance.mjs`"); requireIncludes("GOVERNANCE.md", "Source-lineage URL changes, generated drift, category changes, or pattern count changes."); requireIncludes("GOVERNANCE.md", "Generated structure changes, generated-warning changes, or generated metadata changes."); + requireIncludes("GOVERNANCE.md", "`scripts/generate-consumer-reference-evidence.mjs`"); + requireIncludes("GOVERNANCE.md", "Browser state evidence begins with `scripts/create-component-state-session.mjs`"); + requireIncludes("GOVERNANCE.md", "Receipt creation rejects dirty relevant sources."); + requireIncludes("GOVERNANCE.md", "browser-authored visual sidecars bind the same session, scenario, capture time, source digest, PNG bytes, dimensions, and hash"); + requireIncludes("GOVERNANCE.md", "exactly 30 channel passes over the closed 40-file runtime set"); + requireIncludes("GOVERNANCE.md", "Validation uses the receipt and completed manifest interval, not a wall-clock maximum age"); + for (const profile of ["editorial", "terminal"]) { + for (const artifact of ["state-matrix.md", "keyboard-matrix.md", "evidence-coverage.md"]) { + requireIncludes("GOVERNANCE.md", `design-engineering/reference-profiles/governed-local/${profile}/generated/${artifact}`); + } + } } function requireLifecycleStates() { @@ -109,13 +106,12 @@ function requireOwnership() { } function requireStalenessDecision() { - requireIncludes("GOVERNANCE.md", "scheduled_stale_audit: deferred"); - recommendIncludes("GOVERNANCE.md", "Decision: no scheduled stale-content workflow yet."); requireIncludes("GOVERNANCE.md", "Audit trigger:"); requireIncludes("GOVERNANCE.md", "node scripts/validate-links.mjs --json"); } function requireCiwiring() { + requireIncludes(".github/workflows/validate.yml", "node -c scripts/component-state-workflow-contract.mjs"); requireIncludes(".github/workflows/validate.yml", "node -c scripts/validate-governance.mjs"); requireIncludes(".github/workflows/validate.yml", "node -c scripts/test-validate-governance.mjs"); requireIncludes(".github/workflows/validate.yml", "node scripts/validate-governance.mjs --json"); @@ -128,12 +124,54 @@ function requireCiwiring() { requireIncludes(".github/workflows/validate.yml", "node scripts/test-validate-webpage-workflow.mjs --json"); requireIncludes(".github/workflows/validate.yml", "node -c scripts/validate-consumer-reference.mjs"); requireIncludes(".github/workflows/validate.yml", "node -c scripts/test-validate-consumer-reference.mjs"); + requireIncludes(".github/workflows/validate.yml", "node -c scripts/consumer-reference-case-runner.mjs"); + requireIncludes(".github/workflows/validate.yml", "node -c scripts/governed-profile-registry.mjs"); + requireIncludes(".github/workflows/validate.yml", "node -c scripts/promotion-attestation-contract.mjs"); + requireIncludes(".github/workflows/validate.yml", "node -c scripts/validate-promotion-rfc.mjs"); + requireIncludes(".github/workflows/validate.yml", "node -c scripts/test-validate-promotion-rfc.mjs"); requireIncludes(".github/workflows/validate.yml", "node scripts/validate-consumer-reference.mjs --json"); requireIncludes(".github/workflows/validate.yml", "node scripts/test-validate-consumer-reference.mjs --json"); + requireIncludes(".github/workflows/validate.yml", "node scripts/validate-promotion-rfc.mjs --json"); + requireIncludes(".github/workflows/validate.yml", "node scripts/test-validate-promotion-rfc.mjs --json"); + requireIncludes(".github/workflows/validate.yml", "consumer-reference/schema/promotion-rfc.schema.json"); + requireIncludes(".github/workflows/validate.yml", "consumer-reference/policies/shared-experimental.json"); + requireIncludes(".github/workflows/validate.yml", "node scripts/test-consumer-reference-sentinel.mjs"); + requireIncludes(".github/workflows/validate.yml", "node -c scripts/baseline-schema-parity.mjs"); + requireIncludes(".github/workflows/validate.yml", "node -c scripts/test-component-state-source-contract.mjs"); + requireIncludes(".github/workflows/validate.yml", "node scripts/test-component-state-source-contract.mjs"); + requireIncludes(".github/workflows/validate.yml", "consumer-reference/schema/visual-evidence.schema.json"); + requireIncludes(".github/workflows/validate.yml", "node scripts/create-component-state-session.mjs"); + requireIncludes(".github/workflows/validate.yml", "STATE_SESSION_RECEIPT="); + requireIncludes(".github/workflows/validate.yml", "node scripts/finalize-component-state-evidence.mjs"); + requireIncludes(".github/workflows/validate.yml", "--runtime-manifest"); + requireIncludes(".github/workflows/validate.yml", "node scripts/validate-baseline-manifest.mjs --json"); + requireIncludes(".github/workflows/validate.yml", "node scripts/test-validate-baseline-manifest.mjs --json"); + requireIncludes(".github/workflows/validate.yml", "node scripts/test-summarize-sentinel-calibration.mjs"); + requireIncludes(".github/workflows/validate.yml", "node scripts/validate-renderer-purity.mjs --json"); + requireIncludes(".github/workflows/validate.yml", "checkout_sha=\"$(git -c safe.directory=\"$GITHUB_WORKSPACE\" rev-parse HEAD)\""); + requireIncludes(".github/workflows/validate.yml", "--repository \"changeroa/StyleGallery\" \\"); + requireIncludes(".github/workflows/validate.yml", "--execution-repository \"$GITHUB_REPOSITORY\" \\"); + requireIncludes(".github/workflows/validate.yml", "\"repository\":\"%s\",\"execution_repository\":\"%s\""); + requireIncludes(".github/workflows/validate.yml", "\"changeroa/StyleGallery\" \"$GITHUB_REPOSITORY\" \"$GITHUB_RUN_ID\""); + if (/safe\.directory(?:=|\s+)["']?\*["']?/.test(read(".github/workflows/validate.yml"))) { + failures.push(".github/workflows/validate.yml: broad Git safe.directory wildcard is forbidden"); + } requireIncludes(".github/workflows/validate.yml", "permissions:"); requireIncludes(".github/workflows/validate.yml", "contents: read"); } +function requireComponentStateCiIsolation() { + const relative = ".github/workflows/validate.yml"; + for (const failure of componentStateWorkflowFailures(read(relative))) failures.push(`${relative}: ${failure}`); +} + +function requireImmutableActions() { + const relative = ".github/workflows/validate.yml"; + const workflow = read(relative); + for (const pin of immutableActionPins) if (!workflow.includes(pin)) failures.push(`${relative}: missing immutable action pin ${pin}`); + failures.push(...workflowActionFailures(workflow, relative)); +} + function requireRootLinks() { for (const relative of ["README.md", "index.md", "AGENTS.md"]) { requirePattern(relative, /\[[^\]]+\]\(GOVERNANCE\.md\)/, "link target GOVERNANCE.md"); @@ -154,17 +192,39 @@ function requireEvidenceMap() { requireIncludes("quality/evidence/executable-evidence.md", "Domain metadata, immutable provenance, scope boundaries, and root-route fixtures must fail."); requireIncludes("quality/evidence/executable-evidence.md", "Consumer-reference handoffs, schema/runtime parity"); requireIncludes("quality/evidence/executable-evidence.md", "repository handoff omissions must fail"); + requireIncludes("quality/evidence/executable-evidence.md", "The proposed Chromium sentinel preserves canonical card-grid geometry and truth-derived calibration evidence."); + requireIncludes("quality/evidence/executable-evidence.md", "Governed-local button states retain source-bound visual, DOM, and accessibility-tree evidence across both example profiles."); + requireIncludes("quality/evidence/executable-evidence.md", "one clean capture session binds the browser artifacts to the governed source inventory"); + requireIncludes("quality/evidence/executable-evidence.md", "It does not prove product suitability, independent adoption, full accessibility, cross-browser behavior, or owner approval"); + requireIncludes("GOVERNANCE.md", "node scripts/test-consumer-reference-sentinel.mjs"); requireIncludes("GOVERNANCE.md", "owner.enforcement: \"placeholder\""); requireIncludes("GOVERNANCE.md", "review_independence: \"single_account\""); } +function requireSentinelProvenanceBoundary() { + for (const relative of ["consumer-reference/contract.md", "quality/evidence/executable-evidence.md", "GOVERNANCE.md"]) { + for (const clause of sentinelProvenanceClauses) requireIncludes(relative, clause); + } +} + requireGovernanceMatrix(); +failures.push(...referenceOwnershipFailures(read("GOVERNANCE.md"))); requireLifecycleStates(); requireOwnership(); requireStalenessDecision(); requireCiwiring(); +failures.push(...consumerEvidenceGovernanceFailures({ + evidence: read("quality/evidence/executable-evidence.md"), + freshnessWorkflow: read(".github/workflows/evidence-freshness.yml"), + governance: read("GOVERNANCE.md"), + validationWorkflow: read(".github/workflows/validate.yml"), +})); +requireComponentStateCiIsolation(); +requireImmutableActions(); requireRootLinks(); requireEvidenceMap(); +requireSentinelProvenanceBoundary(); +failures.push(...promotionGovernanceFailures(read)); requireGeneratedWarning("CATALOG.md"); requireGeneratedWarning("patterns/index.md"); requireGeneratedWarning("patterns/stacking/index.md"); @@ -185,4 +245,4 @@ if (json) { console.error(result.failures.join("\n")); } -process.exit(result.ok ? 0 : 1); +process.exitCode = result.ok ? 0 : 1; diff --git a/scripts/validate-ia.mjs b/scripts/validate-ia.mjs index e21b3b9..64bf37a 100644 --- a/scripts/validate-ia.mjs +++ b/scripts/validate-ia.mjs @@ -22,6 +22,30 @@ const domainRoutes = [ ["Platform Guides", "platform-guides/index.md"], ]; const leafDirectories = ["patterns", "recipes", "quality", "motion", "design-engineering", "platform-guides", "consumer-reference"]; +const nestedIndexes = [ + "design-engineering/reference-profiles/index.md", + "design-engineering/reference-profiles/governed-local/index.md", + "design-engineering/reference-profiles/external-adaptation/index.md", +]; +const governedProfileLinks = [ + "[Editorial profile](editorial/profile.json)", + "[Editorial state matrix](editorial/generated/state-matrix.md)", + "[Editorial keyboard matrix](editorial/generated/keyboard-matrix.md)", + "[Editorial evidence coverage](editorial/generated/evidence-coverage.md)", + "[Terminal profile](terminal/profile.json)", + "[Terminal state matrix](terminal/generated/state-matrix.md)", + "[Terminal keyboard matrix](terminal/generated/keyboard-matrix.md)", + "[Terminal evidence coverage](terminal/generated/evidence-coverage.md)", +]; +const migrationRoutes = [ + ["README.md", "[Consumer Migration Readiness](design-engineering/consumer-migration-readiness.md)"], + ["index.md", "[Consumer migration readiness](design-engineering/consumer-migration-readiness.md)"], + ["design-engineering/index.md", "[Consumer Migration Readiness](consumer-migration-readiness.md)"], + ["consumer-reference/index.md", "[Consumer Migration Readiness](../design-engineering/consumer-migration-readiness.md)"], + ["quality/index.md", "[Consumer migration evidence gate](gates/consumer-migration-evidence.md)"], + ["quality/gates/index.md", "[Consumer migration evidence gate](consumer-migration-evidence.md)"], + ["quality/evidence/index.md", "[Consumer migration evidence](consumer-migration.md)"], +]; function read(relative) { const target = path.join(root, relative); @@ -60,6 +84,13 @@ function requireRootRoles() { requireIncludes("quality/index.md", "## Tree-Test Findability QA"); requireIncludes("README.md", "[Consumer Reference](consumer-reference/index.md)"); requireIncludes("index.md", "[Consumer reference](consumer-reference/index.md)"); + requireIncludes("design-engineering/index.md", "[Reference Profiles](reference-profiles/index.md)"); + requireIncludes("design-engineering/reference-profiles/index.md", "[External Adaptation](external-adaptation/index.md)"); + requireIncludes("design-engineering/reference-profiles/index.md", "[Governed Local Profiles](governed-local/index.md)"); + for (const link of governedProfileLinks) { + requireIncludes("design-engineering/reference-profiles/governed-local/index.md", link); + } + for (const [relative, link] of migrationRoutes) requireIncludes(relative, link); } function requireTaskRoutes() { @@ -76,7 +107,7 @@ function requireDomainRoutes() { } function requireLeafNavigation() { - for (const file of leafDirectories.flatMap(walk)) { + for (const file of [...leafDirectories.flatMap(walk), ...nestedIndexes]) { const content = stripFencedCodeBlocks(read(file)); if (!/^Parent: \[[^\]]+\]\([^)]+\)/m.test(content)) failures.push(`${file}: missing Parent navigation link`); if (!/^Next: \[[^\]]+\]\([^)]+\)/m.test(content)) failures.push(`${file}: missing Next navigation link`); @@ -89,7 +120,7 @@ requireDomainRoutes(); requireLeafNavigation(); const result = { - checkedLeafFiles: leafDirectories.flatMap(walk).length, + checkedLeafFiles: leafDirectories.flatMap(walk).length + nestedIndexes.length, failures, ok: failures.length === 0, }; @@ -102,4 +133,4 @@ if (json) { console.error(result.failures.join("\n")); } -process.exit(result.ok ? 0 : 1); +process.exitCode = result.ok ? 0 : 1; diff --git a/scripts/validate-links.mjs b/scripts/validate-links.mjs index b81f1b0..dd828d4 100644 --- a/scripts/validate-links.mjs +++ b/scripts/validate-links.mjs @@ -63,4 +63,4 @@ if (json) { console.error(result.failures.join("\n")); } -process.exit(result.ok ? 0 : 1); +process.exitCode = result.ok ? 0 : 1; diff --git a/scripts/validate-okf.mjs b/scripts/validate-okf.mjs index 8dfb7c9..67cf46f 100644 --- a/scripts/validate-okf.mjs +++ b/scripts/validate-okf.mjs @@ -101,4 +101,4 @@ if (json) { console.error(failures.join("\n")); } -process.exit(result.ok ? 0 : 1); +process.exitCode = result.ok ? 0 : 1; diff --git a/scripts/validate-page-evidence.mjs b/scripts/validate-page-evidence.mjs new file mode 100644 index 0000000..babbc1d --- /dev/null +++ b/scripts/validate-page-evidence.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + addSchemaFindings, + canonicalSourceManifest, + compilePageEvidenceSchemas, + dirtyRelevantSources, + finding, + gitIdentity, + listArtifactFiles, + metadata, + normalizeReference, + PAGE_EVIDENCE_MANIFEST, + PAGE_EVIDENCE_RECEIPT, + readJsonFile, + readContainedBytes, + readReceipt, + resolveContained, + sameJson, + sameStringSet, + withinSession, +} from "./page-evidence-contract.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; + +const styleGalleryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const options = { artifactRoot: undefined, json: false, manifest: undefined, priorManifest: undefined, root: undefined }; +const argumentFailures = []; + +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") options.json = true; + else if (["--artifact-root", "--manifest", "--prior-manifest", "--root"].includes(argument)) { + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) argumentFailures.push(finding("argument_value_required", `${argument} requires a value`, "")); + else { + if (argument === "--artifact-root") options.artifactRoot = path.resolve(process.cwd(), value); + if (argument === "--manifest") options.manifest = path.resolve(process.cwd(), value); + if (argument === "--prior-manifest") options.priorManifest = path.resolve(process.cwd(), value); + if (argument === "--root") options.root = path.resolve(process.cwd(), value); + index += 1; + } + } else argumentFailures.push(finding("argument_unknown", `unsupported argument ${argument}`, "")); +} +for (const [name, value] of [["--root", options.root], ["--artifact-root", options.artifactRoot]]) { + if (!value) argumentFailures.push(finding("argument_value_required", `${name} is required`, "")); +} +if (!options.manifest && options.artifactRoot) options.manifest = path.join(options.artifactRoot, PAGE_EVIDENCE_MANIFEST); + +let schemas; +try { schemas = compilePageEvidenceSchemas(path.join(styleGalleryRoot, "consumer-reference/schema")); } +catch (error) { argumentFailures.push(finding("page_evidence_schema_invalid", error instanceof Error ? error.message : String(error), "consumer-reference/schema")); } + +function checkedArtifact(root, artifact, failures) { + const resolved = readContainedBytes({ prefix: "page_evidence_artifact", reference: artifact?.path, root }, failures); + if (!resolved || !artifact?.media_type) return undefined; + const bytes = resolved.bytes; + const actual = metadata(bytes, artifact.media_type, artifact.path); + if (!sameJson(actual, artifact)) failures.push(finding("page_evidence_artifact_integrity", "artifact bytes or metadata differ from the manifest", artifact.path)); + if (artifact.media_type === "image/png" && (!actual.width || !actual.height)) failures.push(finding("page_evidence_png_invalid", "captured PNG must contain a valid IHDR/IDAT/IEND chunk stream", artifact.path)); + return { actual, bytes, file: resolved.file }; +} + +function validatePacket({ allowReuse, artifactRoot, manifestFile, priorManifestFile, root, seen }) { + const failures = []; + const artifactReference = normalizeReference(root, artifactRoot); + if (!artifactReference) failures.push(finding("page_evidence_artifact_escape", "artifact root must be inside the consumer repository", artifactRoot)); + else if (fs.existsSync(artifactRoot) && fs.lstatSync(artifactRoot).isSymbolicLink()) failures.push(finding("page_evidence_artifact_redirect", "artifact root must not be a filesystem redirect", artifactReference)); + else resolveContained({ expectedType: "directory", prefix: "page_evidence_artifact", reference: artifactReference, root }, failures); + const artifactFiles = failures.length === 0 ? listArtifactFiles(artifactRoot, failures) : []; + if (failures.some((entry) => entry.code === "page_evidence_artifact_limit")) return { failures, manifest: undefined }; + const manifestReference = normalizeReference(artifactRoot, manifestFile); + if (manifestReference !== PAGE_EVIDENCE_MANIFEST) failures.push(finding("page_evidence_manifest_path_invalid", `manifest must be ${PAGE_EVIDENCE_MANIFEST} at artifact-root`, manifestFile)); + const manifestRecord = manifestReference ? readJsonFile({ prefix: "page_evidence_manifest", reference: manifestReference, root: artifactRoot }, failures) : undefined; + const receiptRecord = readReceipt(artifactRoot, schemas, failures); + const receipt = receiptRecord?.valid ? receiptRecord : undefined; + if (!manifestRecord || !receipt) return { failures, manifest: manifestRecord?.value }; + if (!addSchemaFindings(schemas.manifest, manifestRecord.value, manifestReference, "page_evidence_manifest_schema_invalid", failures)) return { failures, manifest: manifestRecord.value }; + const manifest = manifestRecord.value; + const manifestDigest = metadata(manifestRecord.bytes, "application/json", manifestReference).sha256; + if (seen.has(manifestDigest)) { + failures.push(finding("page_evidence_reuse_cycle", "prior manifest chain contains a cycle", manifestReference)); + return { failures, manifest }; + } + seen.add(manifestDigest); + + const session = manifest.session; + const expectedSession = session ? { + attempt: receipt.value.attempt, + branch: receipt.value.branch, + completed_at: session.completed_at, + conformance_record: receipt.value.conformance_record, + environment: receipt.value.environment, + intended_scenario_ids: receipt.value.intended_scenario_ids, + nonce: receipt.value.nonce, + receipt: receipt.artifact, + receipt_sha256: receipt.digest, + repository: receipt.value.repository, + revision: receipt.value.revision, + run_id: receipt.value.run_id, + session_id: receipt.value.session_id, + source: receipt.value.source, + started_at: receipt.value.started_at, + } : undefined; + if (!sameJson(session, expectedSession)) failures.push(finding("page_evidence_session_mismatch", "completed manifest session differs from its immutable start receipt", manifestReference)); + if (manifest.repository !== receipt.value.repository) failures.push(finding("page_evidence_repository_mismatch", "manifest repository differs from the receipt", manifestReference)); + if (manifest.revision !== receipt.value.revision || manifest.run?.revision !== receipt.value.revision) failures.push(finding("page_evidence_revision_mismatch", "manifest revision differs from the receipt", manifestReference)); + if (manifest.run?.id !== receipt.value.run_id || manifest.run?.attempt !== receipt.value.attempt || manifest.run?.repository !== receipt.value.repository) failures.push(finding("page_evidence_run_mismatch", "manifest run differs from the receipt", manifestReference)); + if (manifest.completed_at !== session?.completed_at || !withinSession(session?.completed_at, receipt.value.started_at, session?.completed_at)) failures.push(finding("page_evidence_capture_outside_session", "manifest completion precedes session start", manifestReference)); + + const git = gitIdentity(root, failures); + if (git && git.revision !== receipt.value.revision) failures.push(finding("page_evidence_revision_mismatch", "checked-out revision differs from the evidence receipt", manifestReference)); + if (git?.repository && git.repository !== receipt.value.repository) failures.push(finding("page_evidence_repository_mismatch", "Git remote identity differs from the evidence receipt", manifestReference)); + const currentSource = canonicalSourceManifest(root, receipt.value.source.files.map((entry) => entry.path), failures); + if (currentSource && !sameJson(currentSource, receipt.value.source)) failures.push(finding("page_evidence_source_drift", "relevant source bytes differ from the evidence receipt", manifestReference)); + if (dirtyRelevantSources(root, receipt.value.source.files.map((entry) => entry.path)).length > 0) failures.push(finding("page_evidence_source_drift", "relevant sources are dirty while validating evidence", manifestReference)); + const conformance = readJsonFile({ prefix: "page_evidence_conformance", reference: receipt.value.conformance_record.path, root }, failures); + if (conformance && !sameJson(metadata(conformance.bytes, "application/json", conformance.reference), receipt.value.conformance_record)) failures.push(finding("page_evidence_conformance_drift", "conformance record differs from the evidence receipt", conformance.reference)); + + let priorPacket; + if (priorManifestFile) { + const priorRoot = path.dirname(priorManifestFile); + priorPacket = validatePacket({ allowReuse: false, artifactRoot: priorRoot, manifestFile: priorManifestFile, priorManifestFile: undefined, root, seen }); + failures.push(...priorPacket.failures); + } + const scenarios = Array.isArray(manifest.scenarios) ? manifest.scenarios : []; + const controlFiles = new Set([PAGE_EVIDENCE_MANIFEST, PAGE_EVIDENCE_RECEIPT, ...scenarios.map((scenario) => scenario.runner_result.path)]); + const expectedFiles = new Set(controlFiles); + const scenarioIds = []; + for (const scenario of scenarios) { + scenarioIds.push(scenario.id); + const runnerArtifact = checkedArtifact(artifactRoot, scenario.runner_result, failures); + if (!runnerArtifact) continue; + let runner; + try { runner = parseStrictJson(runnerArtifact.bytes.toString("utf8")); } + catch (error) { failures.push(finding("page_evidence_runner_json_invalid", error instanceof Error ? error.message : String(error), scenario.runner_result.path)); continue; } + if (!addSchemaFindings(schemas.runner, runner, scenario.runner_result.path, "page_evidence_runner_schema_invalid", failures)) continue; + if (runner.status !== "passed") failures.push(finding("page_evidence_runner_failed", `${scenario.id} runner did not pass`, scenario.runner_result.path)); + if (runner.session_id !== receipt.value.session_id || runner.nonce !== receipt.value.nonce || runner.receipt_sha256 !== receipt.digest) failures.push(finding("page_evidence_session_mismatch", `${scenario.id} runner session differs from the receipt`, scenario.runner_result.path)); + if (runner.revision !== receipt.value.revision || runner.run?.revision !== receipt.value.revision) failures.push(finding("page_evidence_revision_mismatch", `${scenario.id} runner revision differs from the receipt`, scenario.runner_result.path)); + if (runner.repository !== receipt.value.repository || runner.run?.repository !== receipt.value.repository) failures.push(finding("page_evidence_repository_mismatch", `${scenario.id} runner repository differs from the receipt`, scenario.runner_result.path)); + if (runner.source_sha256 !== receipt.value.source.sha256) failures.push(finding("page_evidence_source_mismatch", `${scenario.id} runner source differs from the receipt`, scenario.runner_result.path)); + if (!sameJson({ ...runner.run, status: "passed" }, manifest.run)) failures.push(finding("page_evidence_run_mismatch", `${scenario.id} runner identity differs from the closed run`, scenario.runner_result.path)); + if (runner.scenario_id !== scenario.id || runner.recorded_at !== scenario.recorded_at || !sameJson(runner.semantic_environment, scenario.semantic_environment)) failures.push(finding("page_evidence_scenario_mismatch", `${scenario.id} runner scenario differs from the manifest`, scenario.runner_result.path)); + if (!withinSession(runner.recorded_at, receipt.value.started_at, session?.completed_at)) failures.push(finding("page_evidence_capture_outside_session", `${scenario.id} runner was recorded outside the session interval`, scenario.runner_result.path)); + if (scenario.evidence?.kind === "captured") { + const declared = runner.evidence?.kind === "captured" ? runner.evidence.artifacts : []; + const captured = scenario.evidence.artifacts ?? []; + if (!sameJson(declared, captured.map(({ path: artifactPath, media_type: mediaType }) => ({ media_type: mediaType, path: artifactPath })))) failures.push(finding("page_evidence_scenario_mismatch", `${scenario.id} artifact declarations differ from the runner result`, scenario.runner_result.path)); + for (const artifact of captured) { + if (controlFiles.has(artifact.path)) failures.push(finding("page_evidence_artifact_control_collision", "captured artifacts must be disjoint from manifest, receipt, and runner-result controls", artifact.path)); + expectedFiles.add(artifact.path); + checkedArtifact(artifactRoot, artifact, failures); + } + } else if (scenario.evidence?.kind === "reused") { + if (!allowReuse) failures.push(finding("page_evidence_reuse_chain_unsupported", "a reused manifest cannot be reused again", manifestReference)); + if (!sameJson(runner.evidence, scenario.evidence)) failures.push(finding("page_evidence_reuse_identity_mismatch", `${scenario.id} reuse differs from the runner result`, scenario.runner_result.path)); + if (!priorPacket?.manifest) failures.push(finding("page_evidence_reuse_manifest_required", "reused evidence requires a valid prior manifest", manifestReference)); + else { + const prior = priorPacket.manifest; + const priorScenario = prior.scenarios?.find((candidate) => candidate.id === scenario.evidence.prior_scenario_id); + const priorBytes = fs.readFileSync(priorManifestFile); + if (scenario.evidence.prior_manifest_sha256 !== metadata(priorBytes, "application/json", PAGE_EVIDENCE_MANIFEST).sha256) failures.push(finding("page_evidence_reuse_manifest_mismatch", "prior manifest digest does not match", manifestReference)); + if (scenario.evidence.prior_session_id !== prior.session?.session_id || scenario.evidence.prior_run_id !== prior.run?.id) failures.push(finding("page_evidence_reuse_identity_mismatch", "prior session or run identity does not match", manifestReference)); + if (!priorScenario || priorScenario.id !== scenario.id || priorScenario.evidence?.kind !== "captured") failures.push(finding("page_evidence_reuse_scenario_mismatch", "reuse must select the same captured scenario", manifestReference)); + if (!sameJson(prior.session?.source, receipt.value.source)) failures.push(finding("page_evidence_reuse_source_mismatch", "reuse requires byte-identical relevant sources", manifestReference)); + if (priorScenario && !sameJson(priorScenario.semantic_environment, scenario.semantic_environment)) failures.push(finding("page_evidence_reuse_environment_mismatch", "reuse requires the same semantic environment", manifestReference)); + } + } + } + if (!sameStringSet(scenarioIds, receipt.value.intended_scenario_ids)) failures.push(finding("page_evidence_pass_membership_invalid", "PASS scenarios must exactly match the start intent", manifestReference)); + for (const reference of artifactFiles) if (!expectedFiles.has(reference)) failures.push(finding("page_evidence_artifact_unmanifested", "artifact root contains a file outside the closed manifest", reference)); + seen.delete(manifestDigest); + return { failures, manifest }; +} + +let report = { failures: argumentFailures, manifest: undefined }; +if (argumentFailures.length === 0 && schemas) report = validatePacket({ allowReuse: true, artifactRoot: options.artifactRoot, manifestFile: options.manifest, priorManifestFile: options.priorManifest, root: options.root, seen: new Set() }); +const result = { + artifactCount: report.manifest?.scenarios?.reduce((count, scenario) => count + (scenario.evidence?.artifacts?.length ?? 0), 0) ?? 0, + failures: report.failures, + ok: report.failures.length === 0, + scenarioCount: report.manifest?.scenarios?.length ?? 0, + sessionId: report.manifest?.session?.session_id, +}; +if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +else if (result.ok) process.stdout.write(`validated ${result.scenarioCount} page evidence scenarios\n`); +else process.stderr.write(`${result.failures.map((issue) => `${issue.code}: ${issue.path}: ${issue.message}`).join("\n")}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/scripts/validate-patterns.mjs b/scripts/validate-patterns.mjs index 0929d0e..0697157 100644 --- a/scripts/validate-patterns.mjs +++ b/scripts/validate-patterns.mjs @@ -243,4 +243,4 @@ if (json) { console.error(result.failures.join("\n")); } -process.exit(result.ok ? 0 : 1); +process.exitCode = result.ok ? 0 : 1; diff --git a/scripts/validate-promotion-rfc.mjs b/scripts/validate-promotion-rfc.mjs new file mode 100644 index 0000000..d075b21 --- /dev/null +++ b/scripts/validate-promotion-rfc.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; +import { isPlainObject } from "./consumer-reference-schema.mjs"; +import { validateGovernedProfileInventory } from "./governed-profile-registry.mjs"; +import { validatePromotionAttestations } from "./promotion-attestation-contract.mjs"; +import { loadPromotionFixtureInventory, resolvePromotionJsonFile } from "./promotion-fixture-inventory.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const options = { + files: [], + fixtureRoot: path.join(repositoryRoot, "consumer-reference/fixtures/promotion"), + json: false, + policies: [], + profileRepositoryRoot: repositoryRoot, + profileRepositoryRootExplicit: false, +}; +const argumentFailures = []; + +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") options.json = true; + else if (argument === "--file") { + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) argumentFailures.push({ code: "argument_value_required", message: "--file requires a JSON path", path: "" }); + else { + options.files.push(path.resolve(process.cwd(), value)); + index += 1; + } + } else if (argument === "--policy") { + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) argumentFailures.push({ code: "argument_value_required", message: "--policy requires a JSON path", path: "" }); + else { + options.policies.push(path.resolve(process.cwd(), value)); + index += 1; + } + } else if (argument === "--profile-repository-root") { + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) argumentFailures.push({ code: "argument_value_required", message: "--profile-repository-root requires a repository path", path: "" }); + else { + options.profileRepositoryRoot = path.resolve(process.cwd(), value); + options.profileRepositoryRootExplicit = true; + index += 1; + } + } else if (argument === "--promotion-fixture-root") { + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) argumentFailures.push({ code: "argument_value_required", message: "--promotion-fixture-root requires a repository path", path: "" }); + else { + options.fixtureRoot = path.resolve(process.cwd(), value); + index += 1; + } + } else argumentFailures.push({ code: "argument_unknown", message: `unsupported argument ${argument}`, path: "" }); +} + +const failures = [...argumentFailures]; +const warnings = []; +const fixtureInventory = loadPromotionFixtureInventory({ fixtureRoot: options.fixtureRoot, repositoryRoot }); +failures.push(...fixtureInventory.failures); +let validateSchema = () => false; +try { + if (fixtureInventory.schema) validateSchema = new Ajv2020({ allErrors: true, strict: true }).compile(fixtureInventory.schema); +} catch (error) { + failures.push({ code: "promotion_fixture_inventory_invalid", message: error instanceof Error ? error.message : String(error), path: "consumer-reference/schema/promotion-rfc.schema.json" }); +} +const files = options.files.length > 0 + ? options.files + : options.policies.length > 0 + ? [] + : fixtureInventory.validFiles; +const policies = options.files.length === 0 && options.policies.length === 0 + ? [fixtureInventory.policyFile].filter(Boolean) + : options.policies; + +function finding(code, message, recordPath) { + return { code, message, path: recordPath }; +} + +function sameStringSet(actual, expected) { + return Array.isArray(actual) && actual.length === expected.length && actual.every((item) => typeof item === "string") && new Set(actual).size === expected.length && expected.every((item) => actual.includes(item)); +} + +function validateSemantics({ canonicalExample, value, recordPath, failures, warnings }) { + if (!isPlainObject(value)) return; + if (canonicalExample && (value.decision !== "deferred" || !Array.isArray(value.attestations) || value.attestations.length !== 0)) { + failures.push(finding("promotion_canonical_example_invalid", "canonical examples must remain deferred with zero attestations", recordPath)); + } + if (typeof value.review_independence === "boolean") failures.push(finding("review_independence_boolean", "review_independence must use the canonical single_account enum", recordPath)); + if (!isPlainObject(value.owner) || typeof value.owner.migration !== "string" || !isPlainObject(value.migration)) { + failures.push(finding("promotion_owner_migration_required", "promotion proposals require migration ownership and a migration plan", recordPath)); + } + if (value.compatibility?.breaking === true && value.migration?.required !== true) { + failures.push(finding("promotion_migration_required", "breaking compatibility changes require an enabled migration plan", recordPath)); + } + if (value.eligibility_basis === "consumer_count_only") failures.push(finding("promotion_count_only_forbidden", "consumer count cannot replace evidence and lifecycle duties", recordPath)); + if (isPlainObject(value.support) && value.transition?.to === "stable" && value.support.status === "ended") { + failures.push(finding("stable_support_ended", "stable promotion requires active support", recordPath)); + } + if (value.support?.status !== "active" || value.support?.capacity !== "committed") { + failures.push(finding("promotion_support_required", "promotion eligibility requires active, affirmative support capacity", recordPath)); + } + if (value.transition?.from === "stable" && value.transition?.to === "shared_experimental") { + failures.push(finding("promotion_stable_relabel_forbidden", "a stable contract must be maintained, rolled back, or deprecated with migration", recordPath)); + } + const transition = `${value.transition?.from ?? ""}->${value.transition?.to ?? ""}`; + if (!["consumer_local->shared_experimental", "shared_experimental->stable", "stable->shared_experimental"].includes(transition)) { + failures.push(finding("promotion_transition_invalid", `${transition} is not a governed promotion transition`, recordPath)); + } + const evidence = Array.isArray(value.evidence) ? value.evidence.filter(isPlainObject) : []; + const evidenceIds = evidence.map((entry) => entry.id).filter((id) => typeof id === "string"); + if (new Set(evidenceIds).size !== evidenceIds.length) failures.push(finding("promotion_evidence_duplicate", "evidence IDs must be unique", recordPath)); + if (evidence.some((entry) => entry.kind === "blocking" && entry.status === "failed")) { + failures.push(finding("promotion_blocking_evidence_failed", "failed blocking evidence prevents eligibility", recordPath)); + } + const regressionPassed = evidence.some((entry) => entry.kind === "regression" && entry.status === "passed"); + const claimScopedPassed = evidence.some((entry) => entry.kind === "claim_scoped" && entry.status === "passed"); + if (value.normative_bypass === "normative_correctness" && !regressionPassed) { + failures.push(finding("promotion_normative_regression_required", "normative bypass waives adoption count only and requires passing regression evidence", recordPath)); + } + if (value.normative_bypass === "normative_correctness" && value.scope?.kind !== "normative_correctness") { + failures.push(finding("promotion_normative_scope_required", "normative bypass requires a normative correctness scope", recordPath)); + } + if (value.transition?.to === "stable" && (!claimScopedPassed || !regressionPassed)) { + failures.push(finding("promotion_stable_evidence_required", "stable requires passing claim-scoped and regression evidence", recordPath)); + } + const attestationResult = validatePromotionAttestations({ inventory: governedProfileInventory, profileRoot: options.profileRepositoryRoot, recordPath, value }); + failures.push(...attestationResult.failures); + const counted = attestationResult.eligibleCount; + if (value.transition?.from === "consumer_local" && value.transition?.to === "shared_experimental" && value.normative_bypass === "none" && counted < 2) { + warnings.push(finding("promotion_consumer_count_below_gateway", "deferred proposal has fewer than two independent consumers", recordPath)); + } + if (value.transition?.to === "stable" && (value.stable_scope_decision !== true || value.provenance?.debt_resolved !== true)) { + failures.push(finding("promotion_stable_readiness_required", "stable requires explicit scope decision and resolved provenance debt; no numeric count substitutes", recordPath)); + } +} + +function validatePolicy(value, recordPath, failures) { + if (!isPlainObject(value)) { + failures.push(finding("promotion_policy_shape_invalid", "promotion policy must be an object", recordPath)); + return; + } + const allowed = ["artifact_mode", "boundaries", "claims", "gateway", "id", "lifecycle", "normative_bypass", "owner", "review_independence", "schema_version", "status"]; + if (Object.keys(value).some((key) => !allowed.includes(key))) failures.push(finding("promotion_policy_shape_invalid", "promotion policy contains an unknown field", recordPath)); + const nestedShapes = [ + [value.boundaries, ["excluded_content", "promoted_content"], "boundaries"], + [value.claims, ["adopter_attestations", "promotion_occurred"], "claims"], + [value.gateway, ["applies_to", "minimum_independent_consumers", "stable_numeric_threshold", "transition"], "gateway"], + [value.gateway?.transition, ["from", "to"], "gateway.transition"], + [value.lifecycle, ["failure_actions", "stable_relabel"], "lifecycle"], + [value.normative_bypass, ["categories", "retains", "waives"], "normative_bypass"], + [value.owner, ["enforcement", "name"], "owner"], + ]; + for (const [candidate, keys, label] of nestedShapes) { + if (!isPlainObject(candidate) || Object.keys(candidate).some((key) => !keys.includes(key))) failures.push(finding("promotion_policy_shape_invalid", `promotion policy ${label} shape is invalid`, recordPath)); + } + if (value.review_independence !== "single_account") failures.push(finding(typeof value.review_independence === "boolean" ? "review_independence_boolean" : "promotion_policy_shape_invalid", "policy review_independence must be single_account", recordPath)); + if (value.gateway?.transition?.from !== "consumer_local" || value.gateway?.transition?.to !== "shared_experimental" || value.gateway?.applies_to !== "invariant_eligibility_only" || value.gateway?.minimum_independent_consumers !== 2) { + failures.push(finding("promotion_gateway_invalid", "the >=2 gateway applies only to consumer-local invariant eligibility for shared-experimental", recordPath)); + } + if (value.gateway?.stable_numeric_threshold !== null) failures.push(finding("promotion_stable_numeric_threshold_forbidden", "stable has no numeric adoption threshold", recordPath)); + const categories = ["accessibility", "correctness", "security", "standards"]; + const retained = ["compatibility", "deprecation", "migration", "owner_acceptance", "regression_proof", "rollback", "scope_control", "support_capacity"]; + if (!sameStringSet(value.normative_bypass?.categories, categories) || !sameStringSet(value.normative_bypass?.waives, ["adoption_count"]) || !sameStringSet(value.normative_bypass?.retains, retained)) { + failures.push(finding("promotion_normative_bypass_overreach", "normative bypass may waive adoption count only", recordPath)); + } + if (value.claims?.promotion_occurred !== false || value.claims?.adopter_attestations !== 0) failures.push(finding("promotion_claim_forbidden", "the policy records no promotion and zero adopter attestations", recordPath)); + if (value.status !== "active") failures.push(finding("promotion_claim_forbidden", "the policy status must remain active governance, not a promoted decision", recordPath)); + if (value.schema_version !== "1.0" || value.id !== "shared-experimental-policy" || value.artifact_mode !== "schema_only") failures.push(finding("promotion_policy_identity_invalid", "promotion policy identity and artifact mode are canonical", recordPath)); + if (value.owner?.enforcement !== "placeholder" || value.owner?.name !== "Repository governance owner") failures.push(finding("promotion_policy_owner_invalid", "promotion policy must preserve placeholder owner truth", recordPath)); + const excluded = ["component_skin", "imagery", "motion_character", "palette", "typography"]; + const promoted = ["claim_scoped_evidence", "invariant_behavior", "invariant_semantics"]; + if (!sameStringSet(value.boundaries?.excluded_content, excluded)) { + failures.push(finding("promotion_aesthetic_boundary_required", "aesthetic identity must remain excluded from promotion", recordPath)); + } + if (!sameStringSet(value.boundaries?.promoted_content, promoted)) failures.push(finding("promotion_aesthetic_boundary_required", "promoted content must be the invariant-only canonical set", recordPath)); + if (value.lifecycle?.stable_relabel !== "forbidden" || !sameStringSet(value.lifecycle?.failure_actions, ["deprecate_with_migration", "maintain", "restrict", "rollback"])) { + failures.push(finding("promotion_lifecycle_invalid", "failed stable contracts require explicit lifecycle action and migration", recordPath)); + } +} + +const governedProfileInventory = validateGovernedProfileInventory({ root: options.profileRepositoryRoot }); +if (options.profileRepositoryRootExplicit && governedProfileInventory.failures.length > 0) { + failures.push(finding("promotion_profile_registry_invalid", "explicit governed profile repository must fully validate", "")); +} +let checked = 0; +for (const file of files) { + const recordPath = path.relative(repositoryRoot, file) || file; + const resolvedFile = resolvePromotionJsonFile({ file, repositoryRoot }, failures); + if (!resolvedFile) continue; + let value; + try { + value = parseStrictJson(fs.readFileSync(resolvedFile, "utf8")); + checked += 1; + } catch (error) { + failures.push(finding("promotion_json_invalid", error instanceof Error ? error.message : "promotion JSON is invalid", recordPath)); + continue; + } + validateSemantics({ canonicalExample: fixtureInventory.validFiles.includes(resolvedFile), failures, recordPath, value, warnings }); + if (!validateSchema(value)) { + for (const error of validateSchema.errors ?? []) failures.push(finding("promotion_schema_invalid", `${error.instancePath || "/"} ${error.message}`, recordPath)); + } +} +for (const file of policies) { + const recordPath = path.relative(repositoryRoot, file) || file; + const resolvedFile = resolvePromotionJsonFile({ file, repositoryRoot }, failures); + if (!resolvedFile) continue; + try { + const value = parseStrictJson(fs.readFileSync(resolvedFile, "utf8")); + checked += 1; + validatePolicy(value, recordPath, failures); + } catch (error) { + failures.push(finding("promotion_json_invalid", error instanceof Error ? error.message : "promotion policy JSON is invalid", recordPath)); + } +} + +const uniqueFailures = [...new Map(failures.map((entry) => [`${entry.code}:${entry.path}:${entry.message}`, entry])).values()]; +const result = { checked, failures: uniqueFailures, ok: uniqueFailures.length === 0, warnings }; +if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +else if (result.ok) process.stdout.write(`ok: ${checked} promotion proposal records\n`); +else process.stderr.write(`${result.failures.map((entry) => `${entry.code}: ${entry.path}: ${entry.message}`).join("\n")}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/scripts/validate-reference-artifacts.mjs b/scripts/validate-reference-artifacts.mjs new file mode 100644 index 0000000..a1ffb4a --- /dev/null +++ b/scripts/validate-reference-artifacts.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { adapter } from "../consumer-reference/adapters/style-dictionary.config.mjs"; +import { inspectArtifactPath } from "./reference-artifact-path-contract.mjs"; +import { validatePortableTokens } from "./reference-token-contract.mjs"; +import { parseStrictJson } from "./strict-json.mjs"; + +function hash(content) { + return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`; +} + +function cssDeclarations(css) { + return [...css.matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);\s*$/gm)].map((match) => ({ name: match[1], value: match[2].trim() })); +} + +function parseArguments() { + let json = false; + let manifest = "consumer-reference/generated/manifest.json"; + const failures = []; + for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") json = true; + else if (argument === "--manifest") { + manifest = process.argv[index + 1]; + if (!manifest) failures.push({ code: "argument_value_required", message: "--manifest requires a value", path: "" }); + else index += 1; + } else failures.push({ code: "argument_unknown", message: `unsupported argument ${argument}`, path: "" }); + } + return { failures, json, manifest }; +} + +const options = parseArguments(); +const failures = [...options.failures]; +const warnings = []; +const trustRoot = process.cwd(); +const manifestPath = path.resolve(trustRoot, options.manifest || "missing.json"); +let manifest; +const manifestInspection = inspectArtifactPath(trustRoot, manifestPath, false); +if (!manifestInspection.ok && manifestInspection.reason === "missing") { + failures.push({ code: "artifact_manifest_missing", message: "manifest does not exist", path: options.manifest }); + failures.push({ code: "artifact_output_missing", message: "output cannot be located without a manifest", path: options.manifest }); +} else if (!manifestInspection.ok) { + failures.push({ code: "artifact_manifest_untrusted", message: "manifest must be a contained regular non-symlink file", path: options.manifest }); +} else { + try { + manifest = parseStrictJson(fs.readFileSync(manifestPath, "utf8")); + } catch (error) { + failures.push({ code: "artifact_manifest_invalid", message: error instanceof Error ? error.message : String(error), path: options.manifest }); + } +} +if (manifest !== undefined) { + const allowedManifestProperties = new Set(["adapter", "declarations", "inputHash", "outputCount", "outputFile", "outputHash", "schemaVersion", "sourceCount", "sourceFile", "warnings"]); + if (manifest.scaffold === true) failures.push({ code: "artifact_scaffold_forbidden", message: "manifest must not contain a scaffold success sentinel", path: options.manifest }); + for (const key of Object.keys(manifest).filter((key) => !allowedManifestProperties.has(key))) failures.push({ code: "artifact_manifest_property_unknown", message: `manifest contains unsupported property ${key}`, path: options.manifest }); + if (manifest.schemaVersion !== "1.0") failures.push({ code: "artifact_schema_version", message: "manifest schemaVersion must be 1.0", path: options.manifest }); + if (manifest.adapter?.name !== adapter.name || manifest.adapter?.version !== adapter.version) failures.push({ code: "artifact_adapter_mismatch", message: "manifest adapter pin is invalid", path: options.manifest }); + if (!Number.isInteger(manifest.sourceCount) || !Number.isInteger(manifest.outputCount) || manifest.sourceCount <= 0 || manifest.outputCount <= 0) failures.push({ code: "artifact_zero_count", message: "source and output counts must be positive integers", path: options.manifest }); + if (manifest.sourceCount !== manifest.outputCount) failures.push({ code: "artifact_count_mismatch", message: "source and output counts differ", path: options.manifest }); + if (!Array.isArray(manifest.warnings)) failures.push({ code: "artifact_warnings_invalid", message: "warnings must be an array", path: options.manifest }); + else if (manifest.warnings.length > 0) failures.push({ code: "artifact_warning", message: "manifest contains adapter warnings", path: options.manifest }); + const declared = Array.isArray(manifest.declarations) ? manifest.declarations : []; + if (!Array.isArray(manifest.declarations)) failures.push({ code: "artifact_declarations_invalid", message: "declarations must be an array", path: options.manifest }); + const declarationNames = declared.map((item) => item?.name).filter((name) => typeof name === "string"); + if (new Set(declarationNames).size !== declarationNames.length) failures.push({ code: "artifact_declaration_duplicate", message: "manifest contains duplicate declarations", path: options.manifest }); + const sourcePath = typeof manifest.sourceFile === "string" ? path.resolve(path.dirname(manifestPath), manifest.sourceFile) : ""; + const outputPath = typeof manifest.outputFile === "string" ? path.resolve(path.dirname(manifestPath), manifest.outputFile) : ""; + const sourceInspection = sourcePath ? inspectArtifactPath(trustRoot, sourcePath, false) : { ok: false, reason: "missing" }; + const outputInspection = outputPath ? inspectArtifactPath(trustRoot, outputPath, false) : { ok: false, reason: "missing" }; + if (!sourceInspection.ok && sourceInspection.reason === "missing") failures.push({ code: "artifact_source_missing", message: "manifest source file does not exist", path: options.manifest }); + else if (!sourceInspection.ok) failures.push({ code: "token_source_untrusted", message: "manifest source must be a contained regular non-symlink file", path: options.manifest }); + else { + const sourceBytes = fs.readFileSync(sourcePath); + if (manifest.inputHash !== hash(sourceBytes)) failures.push({ code: "artifact_input_hash_mismatch", message: "input hash does not match source", path: options.manifest }); + try { + const contract = validatePortableTokens(parseStrictJson(sourceBytes.toString("utf8"))); + failures.push(...contract.failures); + if (contract.tokens.length !== manifest.sourceCount) failures.push({ code: "artifact_source_count_mismatch", message: "source count does not match canonical tokens", path: options.manifest }); + const expected = contract.tokens.map((token) => `--${token.path.replaceAll(".", "-")}`); + for (const name of expected.filter((name) => !declarationNames.includes(name))) failures.push({ code: "artifact_declaration_missing", message: `manifest omitted ${name}`, path: options.manifest }); + } catch (error) { + failures.push({ code: "artifact_source_invalid", message: error instanceof Error ? error.message : String(error), path: options.manifest }); + } + } + if (!outputInspection.ok && outputInspection.reason === "missing") failures.push({ code: "artifact_output_missing", message: "manifest output file does not exist", path: options.manifest }); + else if (!outputInspection.ok) failures.push({ code: "artifact_output_untrusted", message: "manifest output must be a contained regular non-symlink file", path: options.manifest }); + else { + const css = fs.readFileSync(outputPath, "utf8"); + const emitted = cssDeclarations(css); + const emittedNames = emitted.map((item) => item.name); + if (manifest.outputHash !== hash(css)) failures.push({ code: "artifact_output_hash_mismatch", message: "output hash does not match CSS", path: options.manifest }); + if (emitted.length === 0) failures.push({ code: "artifact_zero_count", message: "CSS contains zero declarations", path: options.manifest }); + if (new Set(emittedNames).size !== emittedNames.length) failures.push({ code: "artifact_declaration_duplicate", message: "CSS contains duplicate declarations", path: options.manifest }); + for (const name of declarationNames.filter((name) => !emittedNames.includes(name))) failures.push({ code: "artifact_declaration_missing", message: `CSS omitted ${name}`, path: options.manifest }); + for (const declaration of declared) { + const actual = emitted.find((item) => item.name === declaration?.name); + if (actual && actual.value !== declaration.value) failures.push({ code: "artifact_declaration_mismatch", message: `CSS value for ${declaration.name} differs from the manifest`, path: options.manifest }); + } + if (emitted.length !== manifest.outputCount || emitted.length !== declarationNames.length) failures.push({ code: "artifact_count_mismatch", message: "CSS, manifest count, and declarations differ", path: options.manifest }); + if (css.includes("[object Object]")) failures.push({ code: "artifact_object_sentinel", message: "CSS contains [object Object]", path: options.manifest }); + if (/\{[a-z0-9.-]+\}|\b(?:null|undefined)\b/.test(css)) failures.push({ code: "artifact_unresolved_value", message: "CSS contains an unresolved value", path: options.manifest }); + if (sourceInspection.ok) { + const rebuildRoot = fs.mkdtempSync(path.join(trustRoot, ".tmp-stylegallery-reference-validation-")); + try { + const expectedOutput = path.join(rebuildRoot, "tokens.css"); + const expectedManifest = path.join(rebuildRoot, "manifest.json"); + const builder = path.join(path.dirname(fileURLToPath(import.meta.url)), "build-reference-artifacts.mjs"); + const child = spawnSync(process.execPath, [builder, "--source", sourcePath, "--output", expectedOutput, "--manifest", expectedManifest, "--adapter", "style-dictionary", "--fail-on-warning", "--json"], { cwd: process.cwd(), encoding: "utf8" }); + if (child.status !== 0 || !fs.existsSync(expectedOutput)) failures.push({ code: "artifact_canonical_rebuild_failed", message: "canonical source could not be rebuilt with the pinned adapter", path: options.manifest }); + else if (fs.readFileSync(expectedOutput, "utf8") !== css) failures.push({ code: "artifact_source_output_mismatch", message: "CSS differs from the pinned adapter output derived from canonical source", path: options.manifest }); + } finally { + fs.rmSync(rebuildRoot, { force: true, recursive: true }); + } + } + } +} +const uniqueFailures = [...new Map(failures.map((item) => [`${item.code}:${item.path}:${item.message}`, item])).values()]; +const report = { failures: uniqueFailures, manifest: options.manifest, ok: uniqueFailures.length === 0, warnings }; +const text = options.json ? JSON.stringify(report, null, 2) : report.ok ? "ok: reference artifacts" : uniqueFailures.map((item) => `${item.code}: ${item.message}`).join("\n"); +(options.json || report.ok ? console.log : console.error)(text); +process.exitCode = report.ok ? 0 : 1; diff --git a/scripts/validate-renderer-purity.mjs b/scripts/validate-renderer-purity.mjs new file mode 100644 index 0000000..7e27882 --- /dev/null +++ b/scripts/validate-renderer-purity.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +let renderer = "tests/helpers/render-consumer-reference.mjs"; +let json = false; +const failures = []; +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument === "--json") json = true; + else if (argument === "--renderer" && process.argv[index + 1]) { + renderer = process.argv[index + 1]; + index += 1; + } else failures.push({ code: "argument_unknown", message: `unsupported argument ${argument}`, path: "" }); +} + +function hashProtectedSources() { + const files = [path.join(repositoryRoot, "CATALOG.md")]; + const queue = [path.join(repositoryRoot, "patterns")]; + while (queue.length > 0) { + const current = queue.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const target = path.join(current, entry.name); + if (entry.isDirectory()) queue.push(target); + else if (entry.isFile()) files.push(target); + } + } + const hash = crypto.createHash("sha256"); + for (const file of files.sort()) hash.update(path.relative(repositoryRoot, file)).update(fs.readFileSync(file)); + return hash.digest("hex"); +} + +const rendererPath = path.resolve(repositoryRoot, renderer); +if (!fs.existsSync(rendererPath)) failures.push({ code: "renderer_missing", message: "renderer helper does not exist", path: renderer }); +else { + const source = fs.readFileSync(rendererPath, "utf8"); + const imports = [...source.matchAll(/^import\s+[^;]+?\s+from\s+["']([^"']+)["'];?$/gm)].map((match) => match[1]); + if (imports.length !== 1 || imports[0] !== "../../scripts/pattern-data.mjs") failures.push({ code: "renderer_import_boundary", message: "renderer may import only scripts/pattern-data.mjs", path: renderer }); + if (/generate-patterns|build-reference-artifacts/.test(source)) failures.push({ code: "renderer_generator_dependency", message: "renderer must not import or call generators", path: renderer }); + if (/node:(?:fs|child_process)|\b(?:writeFile|appendFile|rmSync|renameSync|spawnSync|execSync)\b/.test(source)) failures.push({ code: "renderer_side_effect_capability", message: "renderer must not hold filesystem or process mutation capability", path: renderer }); + if (failures.length === 0) { + const before = hashProtectedSources(); + await import(`${pathToFileURL(rendererPath).href}?purity=${Date.now()}`); + const after = hashProtectedSources(); + if (after !== before) failures.push({ code: "renderer_protected_source_mutation", message: "renderer import changed patterns or CATALOG.md", path: renderer }); + } +} + +const result = { failures, ok: failures.length === 0, warnings: [] }; +if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +else if (!result.ok) process.stderr.write(`${failures.map((failure) => `${failure.code}: ${failure.message}`).join("\n")}\n`); +if (!result.ok) process.exitCode = 1; diff --git a/scripts/validate-vocabulary.mjs b/scripts/validate-vocabulary.mjs index 158735a..a971c02 100644 --- a/scripts/validate-vocabulary.mjs +++ b/scripts/validate-vocabulary.mjs @@ -112,4 +112,4 @@ if (json) { console.error(failures.join("\n")); } -process.exit(result.ok ? 0 : 1); +process.exitCode = result.ok ? 0 : 1; diff --git a/scripts/validate-webpage-workflow.mjs b/scripts/validate-webpage-workflow.mjs index 8be5b5f..1e48e04 100644 --- a/scripts/validate-webpage-workflow.mjs +++ b/scripts/validate-webpage-workflow.mjs @@ -79,6 +79,40 @@ const requiredIncludes = { "Source order, accessibility, brand correctness, and usability cannot be inferred from the image.", "Final implementation proof:", ], + "design-engineering/consumer-migration-readiness.md": [ + "lifecycle: experimental", + "provenance_kind: local", + "# Consumer Migration Readiness", + "## Migration Dimension Contract", + "`behavior_inventory`", + "`route_parity`", + "`field_parity`", + "`action_parity`", + "`state_transitions`", + "`contract_precedence`", + "`direct_mutation`", + "`indirect_mutation`", + "`persistence_round_trip`", + "`reset_boundary`", + "`exact_time_boundary`", + "`defaults_tri_state_mapping`", + "`atomic_batch_behavior`", + "Consumer migration conformance: declared", + "Passing this method does not prove complete accessibility, product correctness, usability, visual quality, independent adoption, cross-browser equivalence, or owner approval.", + ], + "consumer-reference/contract.md": [ + "### Migration-Only Extension", + "This field is migration-only.", + "Do not add it to generic blank handoffs", + "Consumer migration conformance: declared", + ], + "quality/gates/consumer-migration-evidence.md": [ + "# Consumer Migration Evidence Gate", + "## Required Contract", + "## Blocking Conditions", + "thirteen migration dimensions", + "Decision: pass | block", + ], }; const requiredLinks = { @@ -193,6 +227,12 @@ for (const route of workflowRoutes) { requireRouteOrder("guides/webpage-generation-workflow.md", route); } +for (const relative of ["guides/layout-brief.md", "guides/webpage-generation-workflow.md", "quality/gates/harmony-evaluation.md"]) { + if (/^Consumer migration conformance: declared$/m.test(read(relative))) { + failures.push(`${relative}: ordinary handoff must not declare consumer migration conformance`); + } +} + const result = { ok: failures.length === 0, checkedFiles: [...new Set([...Object.keys(requiredIncludes), ...Object.keys(requiredLinks), ...Object.keys(recommendedIncludes)])], @@ -211,4 +251,4 @@ if (json) { console.error(result.failures.join("\n")); } -process.exit(result.ok ? 0 : 1); +process.exitCode = result.ok ? 0 : 1; diff --git a/scripts/visual-expectation-contract.mjs b/scripts/visual-expectation-contract.mjs new file mode 100644 index 0000000..f0c649a --- /dev/null +++ b/scripts/visual-expectation-contract.mjs @@ -0,0 +1,20 @@ +const environmentFields = Object.freeze([ + "browser", + "browser_revision", + "container_image", + "platform", + "playwright", + "viewport", +]); + +function matchesEnvironment(approved, environment) { + return environmentFields.every((field) => approved[field] === environment[field]); +} + +export function visualExpectationFor(scenario, environment, approvedEnvironments) { + const environments = approvedEnvironments.filter((approved) => matchesEnvironment(approved, environment)); + if (environments.length !== 1) throw new Error(`capture environment requires exactly one approved visual environment, found ${environments.length}`); + const matches = scenario.expected.visual_image.filter((expectation) => expectation.environment_id === environments[0].id); + if (matches.length !== 1) throw new Error(`scenario ${scenario.id} requires exactly one approved visual expectation for ${environments[0].id}, found ${matches.length}`); + return matches[0]; +} diff --git a/scripts/workflow-action-contract.mjs b/scripts/workflow-action-contract.mjs new file mode 100644 index 0000000..5ae011a --- /dev/null +++ b/scripts/workflow-action-contract.mjs @@ -0,0 +1,163 @@ +function indentation(line) { + return line.length - line.trimStart().length; +} + +function mappingKeyIndentation(line) { + const sequencePrefix = line.match(/^\s*-\s+/); + return sequencePrefix ? sequencePrefix[0].length : indentation(line); +} + +function mappingEntry(line) { + const match = line.trim().replace(/^-\s+/, "").match(/^(?:([A-Za-z][A-Za-z0-9_-]*)|'([^']*)'|"([^"]*)")\s*:\s*(.*)$/); + if (!match) return null; + return { key: match[1] ?? match[2] ?? match[3], value: match[4] }; +} + +function isMappingHeader(entry, key) { + return entry?.key === key && (entry.value === "" || entry.value.startsWith("#")); +} + +function isBlockScalarHeader(value) { + return /^[|>](?:[+-]?[1-9]|[1-9]?[+-])?(?:\s+#.*)?$/.test(value); +} + +function structuralLines(lines) { + let blockScalarIndentation = null; + return lines.map((line) => { + if (line.trim() === "" || line.trimStart().startsWith("#")) return line; + const lineIndentation = indentation(line); + if (blockScalarIndentation !== null && lineIndentation > blockScalarIndentation) return ""; + blockScalarIndentation = null; + const entry = mappingEntry(line); + if (entry && isBlockScalarHeader(entry.value)) blockScalarIndentation = lineIndentation; + return line; + }); +} + +function notationFailures(lines, relative) { + let actionBlockScalar = false; + let anchorsOrAliases = false; + let directives = false; + let documentMarkers = false; + let escapedDoubleQuotedKey = false; + let explicitMappingKey = false; + let flowCollectionValue = false; + let flowSequenceMapping = false; + let mergeKey = false; + let tags = false; + for (const line of lines) { + if (line.trim() === "" || line.trimStart().startsWith("#")) continue; + const lineIndentation = indentation(line); + const trimmed = line.trim(); + const structural = trimmed.replace(/^-\s+/, ""); + if (lineIndentation === 0 && /^%(?:YAML|TAG)\b/.test(trimmed)) directives = true; + if (lineIndentation === 0 && /^(?:---|\.\.\.)(?:\s+#.*)?$/.test(trimmed)) documentMarkers = true; + if (/^\?(?:\s|$)/.test(structural)) explicitMappingKey = true; + if (/^!\S*(?:\s|$)/.test(structural)) tags = true; + if (/^[&*][^\s:[\]{},]+(?:\s|:|$)/.test(structural)) anchorsOrAliases = true; + if (/^<<\s*:/.test(structural)) mergeKey = true; + const quotedKey = trimmed.match(/^(?:-\s+)?"((?:[^"\\]|\\.)*)"\s*:/)?.[1]; + if (quotedKey?.includes("\\")) escapedDoubleQuotedKey = true; + if (/^-\s*\{/.test(trimmed)) flowSequenceMapping = true; + const entry = mappingEntry(line); + if (/^[\[{]/.test(entry?.value ?? "") || (!/^-\s*\{/.test(trimmed) && /^[\[{]/.test(structural))) flowCollectionValue = true; + if (/^!\S*(?:\s|$)/.test(entry?.value ?? "")) tags = true; + if (/^[&*][^\s[\]{},]+(?:\s|$)/.test(entry?.value ?? "")) anchorsOrAliases = true; + if (entry?.key === "uses" && isBlockScalarHeader(entry.value)) actionBlockScalar = true; + } + const failures = []; + if (directives) failures.push(`${relative}: YAML directives are forbidden`); + if (documentMarkers) failures.push(`${relative}: YAML document markers are forbidden`); + if (explicitMappingKey) failures.push(`${relative}: explicit mapping keys are forbidden`); + if (tags) failures.push(`${relative}: YAML tags are forbidden`); + if (anchorsOrAliases) failures.push(`${relative}: YAML anchors and aliases are forbidden`); + if (mergeKey) failures.push(`${relative}: YAML merge keys are forbidden`); + if (actionBlockScalar) failures.push(`${relative}: block scalar action refs are forbidden`); + if (flowCollectionValue) failures.push(`${relative}: flow-style collection values are forbidden`); + if (flowSequenceMapping) failures.push(`${relative}: flow-style sequence mappings are forbidden`); + if (escapedDoubleQuotedKey) failures.push(`${relative}: escape sequences in double-quoted mapping keys are forbidden`); + return failures; +} + +function directMappingLines(lines, headerIndex, headerIndentation) { + const nested = []; + for (const line of lines.slice(headerIndex + 1)) { + if (line.trim() === "" || line.trimStart().startsWith("#")) continue; + const lineIndentation = indentation(line); + if (lineIndentation <= headerIndentation) break; + nested.push({ indentation: lineIndentation, trimmed: line.trim() }); + } + if (nested.length === 0) return []; + const directIndentation = Math.min(...nested.map((line) => line.indentation)); + return nested.filter((line) => line.indentation === directIndentation).map((line) => line.trimmed); +} + +function actionRefFailures(lines, relative) { + const failures = []; + for (const line of lines) { + const entry = mappingEntry(line); + if (entry?.key !== "uses") continue; + const reference = entry.value.match(/^([^\s#]+)/)?.[1]; + if (!reference) continue; + if (reference.startsWith("./")) continue; + if (reference.startsWith("docker://")) { + failures.push(`${relative}: docker action refs are forbidden ${line.trim().replace(/^-\s*/, "")}`); + continue; + } + if (!/^[^@\s]+@[a-f0-9]{40}$/i.test(reference)) failures.push(`${relative}: floating or unlabeled action ref ${line.trim().replace(/^-\s*/, "")}`); + } + return failures; +} + +export function checkoutCredentialFailures(workflow, relative) { + const failures = []; + const lines = structuralLines(workflow.split("\n")); + for (const [index, line] of lines.entries()) { + const usesEntry = mappingEntry(line); + if (usesEntry?.key !== "uses" || !/^actions\/checkout@/i.test(usesEntry.value)) continue; + const usesIndentation = mappingKeyIndentation(line); + const withHeaders = []; + for (const [offset, following] of lines.slice(index + 1).entries()) { + if (following.trim() === "" || following.trimStart().startsWith("#")) continue; + const followingIndentation = indentation(following); + if (followingIndentation < usesIndentation) break; + if (followingIndentation === usesIndentation && isMappingHeader(mappingEntry(following), "with")) withHeaders.push(index + offset + 1); + } + const entries = withHeaders.length === 1 ? directMappingLines(lines, withHeaders[0], usesIndentation) : []; + const credentialEntries = entries.map(mappingEntry).filter((entry) => entry?.key === "persist-credentials"); + const exactFalse = credentialEntries.length === 1 && /^false(?:\s+#.*)?$/.test(credentialEntries[0].value); + if (!exactFalse) failures.push(`${relative}: every actions/checkout step must set persist-credentials: false`); + } + return failures; +} + +function permissionFailures(lines, relative) { + const failures = []; + const headers = lines + .map((line, index) => ({ entry: mappingEntry(line), index, indentation: indentation(line) })) + .filter(({ entry }) => entry?.key === "permissions"); + if (headers.some((header) => header.indentation > 0)) failures.push(`${relative}: job-level permissions overrides are forbidden`); + const topLevel = headers.filter((header) => header.indentation === 0); + let exact = topLevel.length === 1 && isMappingHeader(topLevel[0].entry, "permissions"); + if (exact) { + const entries = []; + for (const line of lines.slice(topLevel[0].index + 1)) { + if (line.trim() === "" || line.trimStart().startsWith("#")) continue; + if (indentation(line) === 0) break; + entries.push(line.trim()); + } + exact = entries.length === 1 && entries[0] === "contents: read"; + } + if (!exact) failures.push(`${relative}: top-level permissions must be exactly contents: read`); + return failures; +} + +export function workflowActionFailures(workflow, relative) { + const lines = structuralLines(workflow.split("\n")); + return [ + ...notationFailures(lines, relative), + ...actionRefFailures(lines, relative), + ...checkoutCredentialFailures(workflow, relative), + ...permissionFailures(lines, relative), + ]; +} diff --git a/tests/component-state-evidence.spec.mjs b/tests/component-state-evidence.spec.mjs new file mode 100644 index 0000000..57bd864 --- /dev/null +++ b/tests/component-state-evidence.spec.mjs @@ -0,0 +1,151 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; +import { artifactMetadata } from "../scripts/artifact-metadata.mjs"; +import { readCaptureSession } from "../scripts/capture-session-contract.mjs"; +import { compileSchemas } from "../scripts/component-state-contract.mjs"; +import { visualExpectationFor } from "../scripts/visual-expectation-contract.mjs"; +import { componentStateFixture, componentStateProfiles, renderComponentState } from "./helpers/render-component-state.mjs"; + +const mutation = process.env.STATE_MUTATION ?? "none"; +const artifactRoot = process.env.STATE_ARTIFACT_DIR; +const sessionFile = process.env.STATE_SESSION_RECEIPT; +const testRoot = path.dirname(fileURLToPath(import.meta.url)); +const playwrightVersion = JSON.parse(fs.readFileSync(path.resolve(testRoot, "../package.json"), "utf8")).devDependencies["@playwright/test"]; +const sessionFailures = []; +const schemas = compileSchemas(path.resolve(testRoot, "../consumer-reference/schema")); +const captureSession = sessionFile ? readCaptureSession(path.resolve(sessionFile), schemas.capture, sessionFailures) : undefined; +if (artifactRoot && (!captureSession || sessionFailures.length > 0)) throw new Error(`valid STATE_SESSION_RECEIPT is required for capture: ${JSON.stringify(sessionFailures)}`); + +function normalizeBoolean(value) { + return value === true || value === 1 || value === "true" || value === "1"; +} + +async function axObservation(session, fixture, scenario, capturedAt, captureLink) { + const tree = await session.send("Accessibility.getFullAXTree"); + const node = tree.nodes.find((candidate) => candidate.role?.value === scenario.role && candidate.name?.value === scenario.label); + expect(node, `ax_${scenario.id}`).toBeTruthy(); + const raw = Object.fromEntries(node.properties.map((property) => [property.name, property.value.value])); + const properties = {}; + for (const [key, expected] of Object.entries(scenario.expected.ax)) { + if (key !== "role") properties[key] = typeof expected === "boolean" ? normalizeBoolean(raw[key]) : raw[key]; + } + return { capture_session: captureLink, captured_at: capturedAt, channel: "ax", name: node.name.value, profile_id: fixture.profileId, properties, role: node.role.value, scenario_id: scenario.id, schema_version: "1.0", semantic_mode: scenario.semantic_mode }; +} + +async function domObservation(control, fixture, scenario, capturedAt, captureLink) { + const observation = await control.evaluate((element, expected) => { + const attributes = {}; + for (const key of Object.keys(expected.dom)) { + if (key.startsWith("aria-") || key === "disabled") attributes[key] = key === "disabled" ? String(element.disabled) : element.getAttribute(key); + } + return { + activation_count: Number(element.dataset.activations), + activation_key: expected.activationKey, + active: document.activeElement === element, + attributes, + channel: "dom", + profile_id: expected.profileId, + role: element.tagName === "BUTTON" ? "button" : element.getAttribute("role"), + scenario_id: expected.scenarioId, + schema_version: "1.0", + semantic_mode: expected.mode, + visual_states: element.dataset.visual.split(/\s+/).filter(Boolean), + }; + }, { activationKey: scenario.activation_key, dom: scenario.expected.dom, mode: scenario.semantic_mode, profileId: fixture.profileId, scenarioId: scenario.id }); + return { ...observation, capture_session: captureLink, captured_at: capturedAt }; +} + +async function captureEvidence(page, fixture, scenario, session) { + if (!artifactRoot) return; + fs.mkdirSync(artifactRoot, { recursive: true }); + const viewport = page.viewportSize(); + const intendedProfile = captureSession.receipt.intended.find((profile) => profile.profile_id === fixture.profileId && profile.profile_name === fixture.profileName); + const intendedScenario = intendedProfile?.scenarios.find((entry) => entry.id === scenario.id); + expect(intendedScenario?.channels, "capture_session_intent_mismatch").toEqual(["ax", "dom", "visual"]); + expect(captureSession.receipt.environment.browser, "capture_session_browser_mismatch").toContain(page.context().browser().version()); + expect(captureSession.receipt.environment.playwright, "capture_session_playwright_mismatch").toBe(playwrightVersion); + expect(captureSession.receipt.environment.viewport, "capture_session_viewport_mismatch").toBe(`${viewport.width}x${viewport.height}`); + const capturedAt = new Date().toISOString(); + const control = page.locator("#control"); + const stem = `${fixture.profileName}-${scenario.id}`; + const png = await page.locator(".capture").screenshot({ animations: "disabled", caret: "hide" }); + const image = { path: `${stem}.png`, ...artifactMetadata(png, "image/png") }; + const expectation = visualExpectationFor(scenario, captureSession.receipt.environment, fixture.visualEnvironments); + expect(image.sha256, "visual_image_hash_mismatch").toBe(expectation.sha256); + expect(image.width, "visual_image_width_mismatch").toBe(expectation.width); + expect(image.height, "visual_image_height_mismatch").toBe(expectation.height); + const visual = { + capture_session: captureSession.link, + captured_at: capturedAt, + channel: "visual", + image, + profile_id: fixture.profileId, + scenario_id: scenario.id, + schema_version: "1.0", + semantic_mode: scenario.semantic_mode, + source_sha256: captureSession.receipt.source.sha256, + }; + expect(schemas.visual(visual), JSON.stringify(schemas.visual.errors)).toBe(true); + const dom = await domObservation(control, fixture, scenario, capturedAt, captureSession.link); + const ax = await axObservation(session, fixture, scenario, capturedAt, captureSession.link); + fs.writeFileSync(path.join(artifactRoot, `${stem}.png`), png); + fs.writeFileSync(path.join(artifactRoot, `${stem}.visual.json`), `${JSON.stringify(visual, null, 2)}\n`); + fs.writeFileSync(path.join(artifactRoot, `${stem}.dom.json`), `${JSON.stringify(dom, null, 2)}\n`); + fs.writeFileSync(path.join(artifactRoot, `${stem}.ax.json`), `${JSON.stringify(ax, null, 2)}\n`); +} + +for (const profile of componentStateProfiles()) { + const fixture = componentStateFixture(profile); + for (const canonical of fixture.scenarios) { + test(`${profile} ${canonical.id} follows canonical keyboard, visual, DOM, and AX state`, async ({ page }) => { + const scenario = await renderComponentState(page, fixture, canonical.id, mutation); + const control = page.locator("#control"); + const session = await page.context().newCDPSession(page); + await expect(page.locator("[data-scenario-id]"), "state_runtime_unknown_scenario").toHaveCount(1); + await expect(page.locator(".capture"), "state_capture_wrapper").toHaveCSS("padding", "6px"); + + if (scenario.states.includes("disabled")) { + await expect(control, "disabled_surface_mismatch").toBeDisabled(); + await control.click({ force: true }); + } else { + await page.keyboard.press("Tab"); + await expect(control, "focus_surface_mismatch").toBeFocused(); + if (scenario.states.includes("focus")) { + await expect(control, "focus_outline_visible").toHaveCSS("outline-width", "3px"); + await expect(control, "focus_outline_offset_visible").toHaveCSS("outline-offset", "3px"); + } + await page.keyboard.press(scenario.activation_key); + } + + const expectedActivations = scenario.expected.activation === "allowed" ? "1" : "0"; + await expect(control, "state_activation_key_parity").toHaveAttribute("data-activations", expectedActivations); + await expect(control, "visual_surface_parity").toHaveAttribute("data-visual", scenario.expected.visual.join(" ")); + for (const [name, value] of Object.entries(scenario.aria)) await expect(control, `${name}_surface_mismatch`).toHaveAttribute(`aria-${name}`, value); + if (scenario.states.includes("expanded")) { + const panel = page.locator("#controlled-panel"); + await expect(panel, "expanded_surface_mismatch").toBeVisible(); + const clearance = await control.evaluate((element) => { + const controlled = document.getElementById("controlled-panel"); + const style = getComputedStyle(element); + const outlineEdge = element.getBoundingClientRect().bottom + + Number.parseFloat(style.outlineWidth) + + Math.max(0, Number.parseFloat(style.outlineOffset)); + return controlled.getBoundingClientRect().top - outlineEdge; + }); + expect(clearance, "focus_ring_clearance").toBeGreaterThanOrEqual(8); + } + + const dom = await domObservation(control, fixture, scenario); + for (const [name, expected] of Object.entries(scenario.expected.dom)) { + const actual = name === "active" ? String(dom.active) : name === "role" ? dom.role : dom.attributes[name]; + expect(actual, `${name}_surface_mismatch`).toBe(expected); + } + const ax = await axObservation(session, fixture, scenario); + expect(ax.role, "role_surface_mismatch").toBe(scenario.expected.ax.role); + for (const [name, expected] of Object.entries(scenario.expected.ax)) if (name !== "role") expect(ax.properties[name], `${name}_surface_mismatch`).toBe(expected); + await captureEvidence(page, fixture, scenario, session); + }); + } +} diff --git a/tests/consumer-conformance.spec.mjs b/tests/consumer-conformance.spec.mjs new file mode 100644 index 0000000..414b29f --- /dev/null +++ b/tests/consumer-conformance.spec.mjs @@ -0,0 +1,235 @@ +import { expect, test } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { + layoutCases, + overlayCases, + stateCases, + zoomCases, +} from "./fixtures/consumer-conformance-scenarios.mjs"; +import { + capturePageEvidence, + captureVisualQa, + collectContrastViolations, + focusGeometry, + observeRuntimeErrors, + pageGeometry, + renderConsumerConformance, + setChromiumPageScale, +} from "./helpers/render-consumer-conformance.mjs"; + +const mutation = process.env.CONSUMER_CONFORMANCE_MUTATION ?? "none"; +const WCAG_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]; + +function sentinelTag(caseId) { + const tags = { + "dialog-w375": "@sentinel-dialog-focus-leak", + "layout-w320-full-unbroken": "@sentinel-overflow", + "state-w320-loading": "@sentinel-console-error", + "state-w375-default": "@sentinel-low-contrast", + "state-w375-focus": "@sentinel-clipped-focus", + }; + return tags[caseId] ? ` ${tags[caseId]}` : ""; +} + +async function expectGeometry(page) { + const geometry = await pageGeometry(page); + expect(geometry.document.scrollWidth, "document_no_horizontal_overflow").toBeLessThanOrEqual(geometry.document.clientWidth); + for (const owner of geometry.scrollOwners) { + expect(owner.scrollWidth, `scroll_owner_no_horizontal_overflow:${owner.id}`).toBeLessThanOrEqual(owner.clientWidth); + expect(["auto", "scroll"], `scroll_owner_declared:${owner.id}`).toContain(owner.overflowY); + } + for (const essential of geometry.essentials) { + expect(essential.width > 0 && essential.height > 0, `essential_nonzero:${essential.id}`).toBe(true); + expect(essential.intersectsViewport, `essential_observable:${essential.id}`).toBe(true); + expect(essential.fullyVisibleInViewport && essential.fullyVisibleInScrollOwner, `essential_fully_observable:${essential.id}`).toBe(true); + } +} + +async function expectContrast(page, roots = ["body"]) { + const violations = await collectContrastViolations(page, roots); + expect(violations, "color-contrast").toEqual([]); +} + +async function expectAxeClean(page, roots = ["body"]) { + const violations = []; + for (const root of roots) { + const result = await new AxeBuilder({ page }).include(root).withTags(WCAG_TAGS).analyze(); + violations.push(...result.violations.map((violation) => ({ + id: violation.id, + impact: violation.impact, + nodes: violation.nodes.map((node) => node.target), + root, + }))); + } + expect(violations, "axe_declared_wcag_tags").toEqual([]); +} + +async function expectRuntimeClean(errors) { + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(errors, "console_error_free").toEqual([]); +} + +async function expectProtectedPhrases(page, phrases = []) { + if (phrases.length === 0) return; + const lineCounts = await page.locator(".probe-content").evaluate((element, protectedPhrases) => protectedPhrases.map((phrase) => { + const text = element.textContent ?? ""; + const start = text.indexOf(phrase); + if (start < 0 || !element.firstChild) return 0; + const range = document.createRange(); + range.setStart(element.firstChild, start); + range.setEnd(element.firstChild, start + phrase.length); + return [...range.getClientRects()].filter((rect) => rect.width > 0 && rect.height > 0).length; + }), phrases); + expect(lineCounts, "cjk_semantic_phrase_single_line").toEqual(phrases.map(() => 1)); +} + +for (const scenario of layoutCases()) { + test(`layout ${scenario.caseId}${sentinelTag(scenario.caseId)}`, async ({ page }) => { + const errors = observeRuntimeErrors(page); + await page.setViewportSize(scenario.viewport); + await renderConsumerConformance(page, { kind: "layout", mutation, ...scenario }); + await expect(page.locator("main"), "main_landmark_present").toHaveCount(1); + await expect(page.getByRole("heading", { level: 1, name: "Consumer conformance probe" }), "heading_semantics").toBeVisible(); + await expect(page.getByRole("button", { name: scenario.content.label }), "content_action_semantics").toMatchAriaSnapshot(`- button "${scenario.content.label}"`); + await expectGeometry(page); + await expectContrast(page); + await expectAxeClean(page); + await expectProtectedPhrases(page, scenario.content.protectedPhrases); + await expectRuntimeClean(errors); + await captureVisualQa(page, { caseId: scenario.caseId }); + await capturePageEvidence(page, { caseId: scenario.caseId, pageScaleFactor: 1 }); + }); +} + +for (const scenario of stateCases()) { + test(`state ${scenario.caseId}${sentinelTag(scenario.caseId)}`, async ({ page }) => { + const errors = observeRuntimeErrors(page); + await page.setViewportSize(scenario.viewport); + await renderConsumerConformance(page, { kind: "state", mutation, ...scenario }); + const control = page.getByRole("button", { name: "Submit migration" }); + let visualCaptured = false; + await expect(control, "state_button_semantics").toBeVisible(); + + if (scenario.state === "hover") { + await control.hover(); + await expect(control, "hover_state_observed").toHaveAttribute("data-observed-state", "hover"); + } else if (scenario.state === "focus") { + await page.keyboard.press("Tab"); + await expect(control, "focus_state_observed").toBeFocused(); + const geometry = await focusGeometry(control); + expect(geometry.visible && !geometry.clippedBy, "focus_geometry_visible").toBe(true); + } else if (scenario.state === "active") { + const box = await control.boundingBox(); + expect(box, "active_control_box").toBeTruthy(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await expect(control, "active_state_observed").toHaveAttribute("data-observed-state", "active"); + await captureVisualQa(page, { caseId: scenario.caseId }); + visualCaptured = true; + await page.mouse.up(); + } else if (scenario.state === "disabled") { + await expect(control, "disabled_state_semantics").toBeDisabled(); + } else if (scenario.state === "error") { + await expect(control, "error_state_semantics").toHaveAttribute("aria-invalid", "true"); + await expect(page.getByRole("alert"), "error_message_semantics").toHaveText("Migration could not be saved."); + } else if (scenario.state === "loading") { + await expect(control, "loading_state_semantics").toHaveAttribute("aria-busy", "true"); + await expect(page.getByRole("status"), "loading_status_semantics").toHaveText("Saving migration"); + } else { + await expect(control, "default_state_semantics").toHaveAttribute("data-observed-state", "default"); + } + + await expectGeometry(page); + await expectContrast(page); + await expectAxeClean(page); + await expectRuntimeClean(errors); + if (!visualCaptured) await captureVisualQa(page, { caseId: scenario.caseId }); + await capturePageEvidence(page, { caseId: scenario.caseId, pageScaleFactor: 1 }); + }); +} + +for (const scenario of overlayCases()) { + test(`overlay ${scenario.caseId}${sentinelTag(scenario.caseId)}`, async ({ page }) => { + const errors = observeRuntimeErrors(page); + await page.setViewportSize(scenario.viewport); + await renderConsumerConformance(page, { kind: "overlay", mutation, ...scenario }); + const opener = page.getByRole("button", { name: `Open ${scenario.overlay.label}` }); + const overlay = page.getByRole("dialog", { name: scenario.overlay.label }); + const first = overlay.getByRole("button", { name: `First ${scenario.overlay.id} action` }); + const last = overlay.getByRole("button", { name: `Close ${scenario.overlay.label}` }); + const outside = page.locator("#outside-control"); + + await expect(overlay, "overlay_closed_initially").toBeHidden(); + await opener.click(); + await expect(overlay, "overlay_open_semantics").toBeVisible(); + await expect(overlay, "overlay_aria_snapshot").toMatchAriaSnapshot(` + - dialog "${scenario.overlay.label}": + - heading "${scenario.overlay.label}" [level=2] + - button "First ${scenario.overlay.id} action" + - link "${scenario.overlay.label} details" + - button "Close ${scenario.overlay.label}" + `); + await expect(first, "overlay_initial_focus").toBeFocused(); + await page.keyboard.press("Shift+Tab"); + await expect(last, "dialog_focus_trap").toBeFocused(); + await page.keyboard.press("Tab"); + await expect(first, "dialog_focus_trap").toBeFocused(); + const outsideBox = await outside.boundingBox(); + expect(outsideBox, "dialog_background_control_box").toBeTruthy(); + await page.mouse.click(outsideBox.x + outsideBox.width / 2, outsideBox.y + outsideBox.height / 2); + await expect(first, "dialog_pointer_focus_containment").toBeFocused(); + await outside.evaluate((element) => element.focus()); + await expect(first, "dialog_programmatic_focus_containment").toBeFocused(); + await expectContrast(page, ["body", "[data-overlay]:not([hidden])"]); + await expectAxeClean(page, ["body", "[data-overlay]:not([hidden])"]); + await captureVisualQa(page, { caseId: scenario.caseId, suffix: "open" }); + await page.keyboard.press("Escape"); + await expect(overlay, "overlay_escape_close").toBeHidden(); + await expect(opener, "overlay_escape_focus_return").toBeFocused(); + + await opener.click(); + await last.click(); + await expect(overlay, "overlay_button_close").toBeHidden(); + await expect(opener, "overlay_button_focus_return").toBeFocused(); + await expectGeometry(page); + await expectRuntimeClean(errors); + await capturePageEvidence(page, { caseId: scenario.caseId, pageScaleFactor: 1 }); + }); +} + +for (const scenario of zoomCases()) { + test(`zoom ${scenario.caseId}`, async ({ page }) => { + const errors = observeRuntimeErrors(page); + await page.setViewportSize({ height: scenario.viewport.height, width: scenario.viewport.width }); + await renderConsumerConformance(page, { + caseId: scenario.caseId, + container: { id: "medium", inlineSize: "30rem" }, + content: { body: "Zoomed content remains readable and operable.", id: "zoom", label: "Zoom probe" }, + kind: "layout", + mutation, + viewport: scenario.viewport, + }); + await setChromiumPageScale(page, scenario.viewport.pageScaleFactor); + const scale = await page.evaluate(() => window.visualViewport?.scale); + expect(scale, "chromium_page_scale_probe").toBe(scenario.viewport.pageScaleFactor); + await page.keyboard.press("Tab"); + const control = page.getByRole("button", { name: "Zoom probe" }); + await expect(control, "zoom_focus_observable").toBeFocused(); + const essential = await control.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const viewport = window.visualViewport; + const style = getComputedStyle(element); + const focusExtent = (Number.parseFloat(style.outlineWidth) || 0) + Math.max(0, Number.parseFloat(style.outlineOffset) || 0); + return rect.top - focusExtent >= viewport.offsetTop + && rect.left - focusExtent >= viewport.offsetLeft + && rect.bottom + focusExtent <= viewport.offsetTop + viewport.height + && rect.right + focusExtent <= viewport.offsetLeft + viewport.width; + }); + expect(essential, "zoom_essential_observable").toBe(true); + await expectContrast(page); + await expectAxeClean(page); + await expectRuntimeClean(errors); + await captureVisualQa(page, { caseId: scenario.caseId, suffix: "chromium-page-scale-probe" }); + await capturePageEvidence(page, { caseId: scenario.caseId, pageScaleFactor: scenario.viewport.pageScaleFactor, probe: "chromium_page_scale_probe" }); + }); +} diff --git a/tests/consumer-reference-sentinels.spec.mjs b/tests/consumer-reference-sentinels.spec.mjs new file mode 100644 index 0000000..d666e70 --- /dev/null +++ b/tests/consumer-reference-sentinels.spec.mjs @@ -0,0 +1,100 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { expect, test } from "@playwright/test"; +import { BASELINE_ENVIRONMENT, BASELINE_REFERENCE } from "../scripts/baseline-contract.mjs"; +import { renderConsumerReference } from "./helpers/render-consumer-reference.mjs"; + +const mutation = process.env.SENTINEL_MUTATION ?? "none"; +const artifactRoot = process.env.SENTINEL_ARTIFACT_DIR; + +test("canonical consumer reference preserves computed layout before its locator screenshot", async ({ page }) => { + await renderConsumerReference(page, mutation); + const root = page.locator(".card_grid"); + await expect(root).toHaveCount(1); + await expect(root, "computed_layout_visible").toBeVisible(); + await expect(root.locator(":scope > article"), "computed_cardinality").toHaveCount(3); + const computed = await root.evaluate((element) => { + const style = getComputedStyle(element); + const box = element.getBoundingClientRect(); + return { + clientWidth: document.documentElement.clientWidth, + display: style.display, + gap: style.gap, + gridTemplateColumns: style.gridTemplateColumns, + height: box.height, + scrollWidth: document.documentElement.scrollWidth, + width: box.width, + }; + }); + expect(computed.display, "computed_layout_display_grid").toBe("grid"); + expect(computed.gap, "computed_layout_gap").toBe("16px"); + expect(computed.gridTemplateColumns, "computed_layout_columns").not.toBe("none"); + expect(computed.width > 0 && computed.height > 0, "computed_layout_nonzero_box").toBe(true); + expect(computed.scrollWidth <= computed.clientWidth, "computed_long_content_reflow").toBe(true); + const mask = [root.locator(":scope > article")]; + const png = await root.screenshot({ animations: "disabled", caret: "hide", mask }); + const actualHash = crypto.createHash("sha256").update(png).digest("hex"); + const calibrationRun = Number(process.env.SENTINEL_CALIBRATION_RUN ?? 1); + if (artifactRoot) { + fs.mkdirSync(artifactRoot, { recursive: true }); + const dom = await root.evaluate((element) => element.outerHTML); + const ax = await page.locator("body").ariaSnapshot(); + const domBytes = Buffer.from(`${dom}\n`); + const axBytes = Buffer.from(`${ax}\n`); + fs.writeFileSync(path.join(artifactRoot, "actual.png"), png); + fs.writeFileSync(path.join(artifactRoot, "dom.html"), domBytes); + fs.writeFileSync(path.join(artifactRoot, "ax.txt"), axBytes); + const localEnvironment = { + architecture: process.arch === "x64" ? "amd64" : process.arch, + browser_revision: "1228", + browser_version: page.context().browser()?.version() ?? "unknown", + container_image: process.env.SENTINEL_CONTAINER_IMAGE ?? "local-unpinned", + node: process.versions.node, + os: process.platform, + platform: process.platform === "linux" ? "linux/amd64" : `${process.platform}/${process.arch}`, + playwright: "1.61.0", + viewport: page.viewportSize(), + }; + if (process.env.SENTINEL_CALIBRATION_RUN) { + expect(process.platform, "calibration_runtime_platform").toBe("linux"); + expect(localEnvironment.architecture, "calibration_runtime_architecture").toBe("amd64"); + expect(process.versions.node.split(".")[0], "calibration_runtime_node").toBe("22"); + expect(process.env.SENTINEL_CONTAINER_IMAGE, "calibration_runtime_container").toBe(BASELINE_ENVIRONMENT.container_image); + expect(localEnvironment.browser_version, "calibration_runtime_browser_version").toBe(BASELINE_ENVIRONMENT.browser_version); + expect(localEnvironment.viewport, "calibration_runtime_viewport").toEqual(BASELINE_ENVIRONMENT.viewport); + } + const environment = process.env.SENTINEL_CALIBRATION_RUN ? BASELINE_ENVIRONMENT : localEnvironment; + fs.writeFileSync(path.join(artifactRoot, "metadata.json"), `${JSON.stringify({ + architecture: environment.architecture, + ax_sha256: crypto.createHash("sha256").update(axBytes).digest("hex"), + dom_sha256: crypto.createHash("sha256").update(domBytes).digest("hex"), + environment, + metadata_sha256: crypto.createHash("sha256").update(JSON.stringify({ environment, reference: BASELINE_REFERENCE })).digest("hex"), + png_sha256: actualHash, + reference: BASELINE_REFERENCE, + run: calibrationRun, + schema_version: "1.0", + }, null, 2)}\n`); + } + await expect(root, "visual_geometry_matches_proposed_baseline").toHaveScreenshot("consumer-reference-card-grid.png", { + animations: "disabled", + caret: "hide", + mask, + maxDiffPixels: 0, + threshold: 0, + }); + if (artifactRoot) { + fs.writeFileSync(path.join(artifactRoot, "comparison.json"), `${JSON.stringify({ + actual_sha256: actualHash, + assertion: "visual_geometry_matches_proposed_baseline", + diff_pixels: 0, + expected_sha256: BASELINE_REFERENCE.baseline.sha256, + max_diff_pixels: 0, + run: calibrationRun, + schema_version: "1.0", + status: "passed", + threshold: 0, + }, null, 2)}\n`); + } +}); diff --git a/tests/fixtures/consumer-conformance-scenarios.mjs b/tests/fixtures/consumer-conformance-scenarios.mjs new file mode 100644 index 0000000..4d9d5a5 --- /dev/null +++ b/tests/fixtures/consumer-conformance-scenarios.mjs @@ -0,0 +1,80 @@ +export const VIEWPORTS = Object.freeze([ + Object.freeze({ height: 720, width: 320 }), + Object.freeze({ height: 812, width: 375 }), + Object.freeze({ height: 768, width: 768 }), + Object.freeze({ height: 768, width: 1024 }), + Object.freeze({ height: 900, width: 1440 }), +]); + +export const CONTAINERS = Object.freeze([ + Object.freeze({ id: "tight", inlineSize: "240px" }), + Object.freeze({ id: "medium", inlineSize: "32rem" }), + Object.freeze({ id: "roomy", inlineSize: "54rem" }), + Object.freeze({ id: "full", inlineSize: "100%" }), +]); + +export const CONTENTS = Object.freeze([ + Object.freeze({ body: "", id: "empty", label: "Empty content probe" }), + Object.freeze({ body: "A deliberately long navigation label that must wrap without widening its container", id: "long-label", label: "Long label probe" }), + Object.freeze({ + body: "Migration evidence remains consumer-owned. This intentionally long paragraph exercises line wrapping, intrinsic sizing, and stable reading order without claiming that a synthetic fixture certifies a product.", + id: "long-paragraph", + label: "Long paragraph probe", + }), + Object.freeze({ + body: "소비자가\u00a0증거를\u00a0실행합니다. 소비자가\u00a0결과를\u00a0검증합니다. 긴\u00a0문장도\u00a0잘\u00a0줄바꿈됩니다. 의미도\u00a0온전히\u00a0유지됩니다.", + id: "cjk", + label: "CJK content probe", + protectedPhrases: Object.freeze([ + "소비자가\u00a0증거를\u00a0실행합니다.", + "소비자가\u00a0결과를\u00a0검증합니다.", + "긴\u00a0문장도\u00a0잘\u00a0줄바꿈됩니다.", + "의미도\u00a0온전히\u00a0유지됩니다.", + ]), + }), + Object.freeze({ body: `unbroken-${"consumerconformance".repeat(12)}`, id: "unbroken", label: "Unbroken content probe" }), +]); + +export const STATES = Object.freeze(["default", "hover", "focus", "active", "disabled", "error", "loading"]); + +export const OVERLAYS = Object.freeze([ + Object.freeze({ id: "drawer", label: "Navigation drawer" }), + Object.freeze({ id: "dialog", label: "Migration confirmation" }), +]); + +export const ZOOM_CASES = Object.freeze([ + Object.freeze({ height: 768, pageScaleFactor: 2, width: 1024 }), + Object.freeze({ height: 900, pageScaleFactor: 2, width: 1440 }), +]); + +export function layoutCases() { + return VIEWPORTS.flatMap((viewport) => CONTAINERS.flatMap((container) => CONTENTS.map((content) => ({ + caseId: `layout-w${viewport.width}-${container.id}-${content.id}`, + container, + content, + viewport, + })))); +} + +export function stateCases() { + return VIEWPORTS.flatMap((viewport) => STATES.map((state) => ({ + caseId: `state-w${viewport.width}-${state}`, + state, + viewport, + }))); +} + +export function overlayCases() { + return VIEWPORTS.flatMap((viewport) => OVERLAYS.map((overlay) => ({ + caseId: `${overlay.id}-w${viewport.width}`, + overlay, + viewport, + }))); +} + +export function zoomCases() { + return ZOOM_CASES.map((viewport) => ({ + caseId: `zoom-w${viewport.width}-scale${viewport.pageScaleFactor}`, + viewport, + })); +} diff --git a/tests/helpers/render-component-state.mjs b/tests/helpers/render-component-state.mjs new file mode 100644 index 0000000..01a759b --- /dev/null +++ b/tests/helpers/render-component-state.mjs @@ -0,0 +1,134 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveProfileRecords } from "../../scripts/profile-record-contract.mjs"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const governedRoot = path.join(repositoryRoot, "design-engineering/reference-profiles/governed-local"); + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function tokenValue(tokens, reference) { + const segments = reference.slice(1, -1).split("."); + let current = tokens; + for (const segment of segments) current = current[segment]; + return current.$value; +} + +function cssColor(value) { + const channels = value.components.map((component) => Math.round(component * 255)); + return `rgb(${channels.join(" ")} / ${value.alpha})`; +} + +function cssDimension(value) { + return `${value.value}${value.unit}`; +} + +function escapeHtml(value) { + return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} + +export function componentStateProfiles() { + return fs.readdirSync(governedRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(governedRoot, entry.name, "profile.json"))) + .map((entry) => entry.name) + .sort(); +} + +export function componentStateFixture(profileName) { + const profileRoot = path.join(governedRoot, profileName); + const failures = []; + const resolved = resolveProfileRecords(profileRoot, failures); + if (!resolved || failures.length > 0) throw new Error(`invalid governed profile ${profileName}: ${JSON.stringify(failures)}`); + const foundations = readJson(path.join(profileRoot, resolved.profile.local_foundations)); + const tokens = readJson(path.join(profileRoot, resolved.profile.tokens)); + const component = resolved.records.component[0].value; + const fixture = resolved.records.fixture[0].value; + const states = resolved.records.states[0].value; + const modes = new Map(component.semantic_modes.map((mode) => [mode.id, mode])); + const scenarios = new Map(states.scenarios.map((scenario) => [scenario.id, scenario])); + return { + colors: { + accent: cssColor(tokenValue(tokens, foundations.bindings.accent)), + canvas: cssColor(tokenValue(tokens, foundations.bindings.canvas)), + text: cssColor(tokenValue(tokens, foundations.bindings.text)), + }, + font: foundations.bindings.body_font_family, + profileId: resolved.profile.id, + profileName, + scenarios: fixture.scenarios.map((runtime) => ({ + ...runtime, + aria: scenarios.get(runtime.id).aria, + expected: scenarios.get(runtime.id).expected, + states: scenarios.get(runtime.id).states, + role: modes.get(runtime.semantic_mode).role, + })), + spacing: { + page: cssDimension(tokens.space.page.$value), + rhythm: cssDimension(tokenValue(tokens, foundations.bindings.content_rhythm)), + }, + visualEnvironments: states.visual_environments, + }; +} + +export async function renderComponentState(page, fixture, scenarioId, mutation = "none") { + const scenario = fixture.scenarios.find((candidate) => candidate.id === scenarioId); + if (!scenario) throw new Error(`unknown canonical scenario ${scenarioId}`); + const isDisabled = scenario.states.includes("disabled") && mutation !== "runtime-disabled-activation"; + const initialVisual = scenario.expected.visual.filter((state) => !["expanded", "pressed"].includes(state)); + const attributes = Object.entries(scenario.aria) + .map(([name, value]) => `aria-${name}="${escapeHtml(name === "expanded" || name === "pressed" ? "false" : value)}"`) + .join(" "); + const panel = scenario.semantic_mode === "disclosure" ? `` : ""; + const unknown = mutation === "runtime-unknown-scenario" ? '' : ""; + const runtime = JSON.stringify({ + activation: scenario.expected.activation, + activationKey: scenario.activation_key, + mode: scenario.semantic_mode, + mutation, + visual: scenario.expected.visual, + }).replaceAll("<", "\\u003c"); + await page.setContent(` +
+
+ + ${panel} +
+ ${unknown} +
+ + `); + return scenario; +} diff --git a/tests/helpers/render-consumer-conformance.mjs b/tests/helpers/render-consumer-conformance.mjs new file mode 100644 index 0000000..9eb4bce --- /dev/null +++ b/tests/helpers/render-consumer-conformance.mjs @@ -0,0 +1,453 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +const DEFAULT_EVIDENCE_CASE = "state-w1024-focus"; +const identityPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const scenarioPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function commonStyles(mutation) { + const text = mutation === "low-contrast" ? "rgb(156 163 175)" : "rgb(31 41 55)"; + const overflow = mutation === "overflow" ? "body::after{content:'';display:block;inline-size:200vw;block-size:1px}" : ""; + const clippedFocus = mutation === "clipped-focus" ? ".focus-frame{overflow:hidden;padding:0}" : ""; + return ` + :root{ + --canvas:rgb(255 255 255); + --error-canvas:rgb(254 242 242); + --error-text:rgb(153 27 27); + --focus:rgb(180 83 9); + --muted:rgb(75 85 99); + --primary:rgb(29 78 216); + --primary-active:rgb(30 58 138); + --primary-hover:rgb(30 64 175); + --surface:rgb(248 250 252); + --text:${text}; + } + *,*::before,*::after{box-sizing:border-box} + html,body{margin:0;min-block-size:100%} + body{background:var(--canvas);color:var(--text);font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.5;padding:16px} + button,a{font:inherit} + button{background:var(--primary);border:2px solid var(--primary);color:rgb(255 255 255);cursor:pointer;min-block-size:44px;padding:8px 16px} + button:hover{background:var(--primary-hover)} + button:active{background:var(--primary-active)} + button:disabled{background:rgb(71 85 105);border-color:rgb(71 85 105);cursor:not-allowed} + button:focus-visible,a:focus-visible{outline:3px solid var(--focus);outline-offset:3px} + h1,h2,p{margin-block:0} + h1{font-size:clamp(1.5rem,4vw,2rem);line-height:1.2} + main{display:grid;gap:16px;inline-size:100%;margin-inline:auto;max-inline-size:72rem;min-inline-size:0} + .probe-container{background:var(--surface);display:grid;gap:12px;inline-size:min(100%,var(--container-inline-size));max-block-size:calc(100vh - 96px);min-inline-size:0;overflow:auto;padding:16px} + .probe-content{min-inline-size:0;overflow-wrap:anywhere;word-break:normal} + .probe-content[data-content-kind="cjk"]{word-break:keep-all} + .focus-frame{inline-size:min(100%,32rem);padding:8px} + .focus-frame>button{inline-size:100%} + .state-error{background:var(--error-canvas);color:var(--error-text);padding:8px} + .state-status{color:var(--muted);padding:8px} + .overlay-backdrop{background:rgb(15 23 42 / .35);inset:0;position:fixed;z-index:1} + .overlay-shell{background:var(--surface);display:grid;gap:12px;inset:16px;max-block-size:calc(100vh - 32px);overflow:auto;padding:24px;position:fixed;z-index:2} + .overlay-shell[data-kind="drawer"]{inset-inline-start:auto;inline-size:min(22rem,calc(100vw - 32px))} + .overlay-shell[data-kind="dialog"]{inset:50% auto auto 50%;inline-size:min(32rem,calc(100vw - 32px));transform:translate(-50%,-50%)} + .overlay-actions{display:grid;gap:12px} + .overlay-actions>a{color:rgb(29 78 216);padding:8px} + [hidden]{display:none!important} + ${overflow} + ${clippedFocus} + `; +} + +function layoutMarkup(options) { + const content = options.content; + return ` +
+

Consumer conformance probe

+
+

Container probe

+

${escapeHtml(content.body)}

+ +
+
+ `; +} + +function stateMarkup(options) { + const state = options.state; + const attributes = [ + state === "disabled" ? "disabled" : "", + state === "error" ? 'aria-describedby="migration-error" aria-invalid="true"' : "", + state === "loading" ? 'aria-busy="true" aria-describedby="migration-status"' : "", + ].filter(Boolean).join(" "); + const message = state === "error" + ? '' + : state === "loading" + ? '

Saving migration

' + : ""; + return ` +
+

Consumer conformance probe

+
+
+ +
+ ${message} +
+
+ `; +} + +function overlayMarkup(options) { + const overlay = options.overlay; + return ` +
+

Consumer conformance probe

+ + + + +
+ `; +} + +function runtimeScript(options) { + const runtime = JSON.stringify({ kind: options.kind, mutation: options.mutation }).replaceAll("<", "\\u003c"); + return ` + const runtime = ${runtime}; + const stateControl = document.getElementById("state-control"); + if (stateControl) { + const intended = stateControl.dataset.intendedState; + if (intended === "hover") stateControl.addEventListener("pointerenter", () => { stateControl.dataset.observedState = "hover"; }); + if (intended === "focus") stateControl.addEventListener("focus", () => { stateControl.dataset.observedState = "focus"; }); + if (intended === "active") { + stateControl.addEventListener("pointerdown", () => { stateControl.dataset.observedState = "active"; }); + stateControl.addEventListener("pointerup", () => { stateControl.dataset.observedState = "default"; }); + } + } + const opener = document.getElementById("overlay-opener"); + const overlay = document.getElementById("consumer-overlay"); + const backdrop = document.getElementById("overlay-backdrop"); + const first = document.getElementById("overlay-first"); + const close = document.getElementById("overlay-close"); + if (opener && overlay && backdrop && first && close) { + const background = [...document.querySelectorAll("#consumer-main > :not(#consumer-overlay):not(#overlay-backdrop)")]; + const containFocus = runtime.mutation !== "dialog-focus-leak"; + const closeOverlay = () => { + overlay.hidden = true; + backdrop.hidden = true; + for (const element of background) element.inert = false; + opener.setAttribute("aria-expanded", "false"); + opener.focus(); + }; + opener.setAttribute("aria-controls", "consumer-overlay"); + opener.setAttribute("aria-expanded", "false"); + opener.addEventListener("click", () => { + overlay.hidden = false; + if (containFocus) { + backdrop.hidden = false; + for (const element of background) element.inert = true; + } + opener.setAttribute("aria-expanded", "true"); + first.focus(); + }); + close.addEventListener("click", closeOverlay); + document.addEventListener("pointerdown", (event) => { + if (!containFocus || overlay.hidden) return; + const interactive = event.target.closest('button,a[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'); + if (!overlay.contains(event.target) || !interactive) event.preventDefault(); + }, true); + document.addEventListener("focusin", (event) => { + if (containFocus && !overlay.hidden && !overlay.contains(event.target)) first.focus(); + }); + document.addEventListener("keydown", (event) => { + if (overlay.hidden) return; + if (event.key === "Escape") { event.preventDefault(); closeOverlay(); return; } + if (event.key !== "Tab" || !containFocus) return; + const focusable = [...overlay.querySelectorAll('button:not([disabled]),a[href]')]; + if (event.shiftKey && document.activeElement === focusable[0]) { + event.preventDefault(); + focusable.at(-1).focus(); + } else if (!event.shiftKey && document.activeElement === focusable.at(-1)) { + event.preventDefault(); + focusable[0].focus(); + } + }); + } + if (runtime.mutation === "console-error") console.error("deliberate consumer conformance mutation"); + `; +} + +export async function renderConsumerConformance(page, options) { + const markup = options.kind === "layout" + ? layoutMarkup(options) + : options.kind === "state" + ? stateMarkup(options) + : overlayMarkup(options); + await page.setContent(`Consumer conformance probe${markup}`); +} + +export function observeRuntimeErrors(page) { + const errors = []; + page.on("console", (message) => { + if (message.type() === "error") errors.push(`console:${message.text()}`); + }); + page.on("pageerror", (error) => errors.push(`page:${error.message}`)); + return errors; +} + +export async function pageGeometry(page) { + return page.evaluate(() => { + const viewport = { height: window.innerHeight, width: window.innerWidth }; + const observation = (element) => { + const rect = element.getBoundingClientRect(); + const scrollOwner = element.closest("[data-scroll-owner]"); + const ownerRect = scrollOwner?.getBoundingClientRect(); + return { + fullyVisibleInScrollOwner: !ownerRect || (rect.left >= ownerRect.left && rect.right <= ownerRect.right && rect.top >= ownerRect.top && rect.bottom <= ownerRect.bottom), + fullyVisibleInViewport: rect.left >= 0 && rect.right <= viewport.width && rect.top >= 0 && rect.bottom <= viewport.height, + height: rect.height, + id: element.id || element.dataset.essential || element.dataset.scrollOwner || element.tagName.toLowerCase(), + intersectsViewport: rect.bottom > 0 && rect.right > 0 && rect.top < viewport.height && rect.left < viewport.width, + width: rect.width, + }; + }; + return { + document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth }, + essentials: [...document.querySelectorAll("[data-essential]")].map(observation), + scrollOwners: [...document.querySelectorAll("[data-scroll-owner]")].map((element) => ({ + clientWidth: element.clientWidth, + id: element.dataset.scrollOwner, + overflowY: getComputedStyle(element).overflowY, + scrollWidth: element.scrollWidth, + })), + }; + }); +} + +export async function focusGeometry(locator) { + return locator.evaluate((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + const outlineWidth = Number.parseFloat(style.outlineWidth) || 0; + const outlineOffset = Number.parseFloat(style.outlineOffset) || 0; + const extent = outlineWidth + Math.max(0, outlineOffset); + const ring = { bottom: rect.bottom + extent, left: rect.left - extent, right: rect.right + extent, top: rect.top - extent }; + let clippedBy = null; + for (let ancestor = element.parentElement; ancestor && !clippedBy; ancestor = ancestor.parentElement) { + const ancestorStyle = getComputedStyle(ancestor); + if (![ancestorStyle.overflow, ancestorStyle.overflowX, ancestorStyle.overflowY].some((value) => value === "hidden" || value === "clip")) continue; + const bounds = ancestor.getBoundingClientRect(); + if (ring.left < bounds.left || ring.right > bounds.right || ring.top < bounds.top || ring.bottom > bounds.bottom) clippedBy = ancestor.id || ancestor.className || ancestor.tagName.toLowerCase(); + } + const visible = document.activeElement === element + && outlineWidth >= 2 + && style.outlineStyle !== "none" + && ring.left >= 0 + && ring.top >= 0 + && ring.right <= window.innerWidth + && ring.bottom <= window.innerHeight; + return { clippedBy, outlineOffset, outlineWidth, ring, visible }; + }); +} + +export async function collectContrastViolations(page, rootSelectors) { + return page.evaluate((selectors) => { + function parseColor(value) { + const match = value.match(/^rgba?\((.*)\)$/); + if (!match) return null; + const parts = match[1].trim().split(/[\s,\/]+/).filter(Boolean).map(Number); + if (parts.length < 3 || parts.slice(0, 3).some(Number.isNaN)) return null; + return { alpha: Number.isFinite(parts[3]) ? parts[3] : 1, channels: parts.slice(0, 3) }; + } + function composite(foreground, background) { + const alpha = foreground.alpha; + return foreground.channels.map((channel, index) => channel * alpha + background[index] * (1 - alpha)); + } + function backgroundFor(element) { + const ancestors = []; + for (let current = element; current; current = current.parentElement) ancestors.push(current); + let background = [255, 255, 255]; + for (const ancestor of ancestors.reverse()) { + const parsed = parseColor(getComputedStyle(ancestor).backgroundColor); + if (parsed && parsed.alpha > 0) background = composite(parsed, background); + } + return background; + } + function luminance(channels) { + const linear = channels.map((value) => { + const channel = value / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; + } + function ratio(foreground, background) { + const foregroundLuminance = luminance(foreground); + const backgroundLuminance = luminance(background); + return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) / (Math.min(foregroundLuminance, backgroundLuminance) + 0.05); + } + + const roots = selectors.flatMap((selector) => [...document.querySelectorAll(selector)]); + const elements = new Set(roots.flatMap((root) => [root, ...root.querySelectorAll("*")])); + const violations = []; + for (const element of elements) { + const hasDirectText = [...element.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim().length > 0); + if (!hasDirectText) continue; + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + if (style.display === "none" || style.visibility === "hidden" || rect.width === 0 || rect.height === 0) continue; + const foreground = parseColor(style.color); + if (!foreground) continue; + const background = backgroundFor(element); + const actualForeground = composite(foreground, background); + const actualRatio = ratio(actualForeground, background); + if (actualRatio < 4.5) violations.push({ + actual: Number(actualRatio.toFixed(2)), + element: `${element.tagName.toLowerCase()}${element.id ? `#${element.id}` : ""}`, + required: 4.5, + text: element.textContent.trim().slice(0, 80), + }); + } + return violations; + }, rootSelectors); +} + +export async function setChromiumPageScale(page, pageScaleFactor) { + const session = await page.context().newCDPSession(page); + try { + await session.send("Emulation.setPageScaleFactor", { pageScaleFactor }); + } finally { + await session.detach(); + } +} + +export async function captureVisualQa(page, { caseId, suffix = "rendered" } = {}) { + const configuredRoot = process.env.CONSUMER_CONFORMANCE_CAPTURE_DIR; + if (!configuredRoot) return; + const root = path.resolve(configuredRoot); + const safeCase = String(caseId).replaceAll(/[^a-z0-9-]/g, "-"); + const safeSuffix = String(suffix).replaceAll(/[^a-z0-9-]/g, "-"); + if (!safeCase || !safeSuffix) throw new Error("consumer conformance capture requires a safe case and suffix"); + const file = path.join(root, `${safeCase}-${safeSuffix}.png`); + if (!file.startsWith(`${root}${path.sep}`)) throw new Error("consumer conformance capture escaped its root"); + fs.mkdirSync(root, { recursive: true }); + if (fs.existsSync(file)) throw new Error(`consumer conformance capture already exists: ${file}`); + await page.screenshot({ animations: "disabled", caret: "hide", path: file }); +} + +function resolveEvidencePath(root, reference) { + if (typeof reference !== "string" || reference.length === 0 || path.isAbsolute(reference) || reference.includes("\\") || reference.split("/").some((segment) => segment === ".." || segment === "." || segment.length === 0)) { + throw new Error(`invalid PAGE_EVIDENCE path: ${reference}`); + } + const resolved = path.resolve(root, reference); + if (!resolved.startsWith(`${root}${path.sep}`)) throw new Error(`PAGE_EVIDENCE path escapes artifact root: ${reference}`); + return resolved; +} + +function pathEntry(file) { + try { + return fs.lstatSync(file); + } catch (error) { + if (error?.code === "ENOENT") return undefined; + throw error; + } +} + +function prepareEvidenceOutput(root, reference) { + const resolved = resolveEvidencePath(root, reference); + const rootEntry = pathEntry(root); + if (!rootEntry?.isDirectory() || rootEntry.isSymbolicLink()) throw new Error("page_evidence_artifact_root_invalid: artifact root must be a real directory"); + const canonicalRoot = fs.realpathSync(root); + const parentReference = path.posix.dirname(reference); + let directory = root; + + for (const segment of parentReference === "." ? [] : parentReference.split("/")) { + directory = path.join(directory, segment); + let entry = pathEntry(directory); + if (!entry) { + fs.mkdirSync(directory); + entry = pathEntry(directory); + } + if (entry.isSymbolicLink()) throw new Error(`page_evidence_output_symlink: ${reference}`); + if (!entry.isDirectory()) throw new Error(`page_evidence_output_parent_invalid: ${reference}`); + const canonicalDirectory = fs.realpathSync(directory); + if (canonicalDirectory !== canonicalRoot && !canonicalDirectory.startsWith(`${canonicalRoot}${path.sep}`)) throw new Error(`page_evidence_output_escape: ${reference}`); + } + + if (pathEntry(resolved)) throw new Error(`page_evidence_output_exists: ${reference}`); + return resolved; +} + +export async function capturePageEvidence(page, { caseId, pageScaleFactor, probe } = {}) { + const configuredRoot = process.env.PAGE_EVIDENCE_ARTIFACT_ROOT ?? process.env.PAGE_EVIDENCE_ARTIFACT_DIR; + if (!configuredRoot) return; + const selectedCase = process.env.PAGE_EVIDENCE_CASE_ID ?? DEFAULT_EVIDENCE_CASE; + if (caseId !== selectedCase) return; + + const root = path.resolve(configuredRoot); + const receiptFile = path.resolve(process.env.PAGE_EVIDENCE_SESSION_RECEIPT ?? path.join(root, "page-evidence-session.json")); + const receiptBytes = fs.readFileSync(receiptFile); + const receipt = JSON.parse(receiptBytes); + const intended = receipt.intended_scenario_ids; + const scenarioId = process.env.PAGE_EVIDENCE_SCENARIO_ID ?? (intended?.length === 1 ? intended[0] : undefined); + if (!scenarioPattern.test(scenarioId ?? "") || !intended?.includes(scenarioId)) throw new Error("PAGE_EVIDENCE_SCENARIO_ID must select one intended receipt scenario"); + if (!identityPattern.test(receipt.run_id ?? "") || !identityPattern.test(receipt.session_id ?? "")) throw new Error("PAGE_EVIDENCE receipt identities are invalid"); + + const safeCase = caseId.replaceAll(/[^a-z0-9-]/g, "-"); + const probeSlug = probe ? `-${probe.replaceAll(/[^a-z0-9-]/gi, "-").toLowerCase()}` : ""; + const screenshotReference = `captures/${scenarioId}-${safeCase}${probeSlug}.png`; + const runnerReference = process.env.PAGE_EVIDENCE_RUNNER_RESULT ?? `runner/${scenarioId}.json`; + fs.mkdirSync(root, { recursive: true }); + const screenshotFile = prepareEvidenceOutput(root, screenshotReference); + const runnerFile = prepareEvidenceOutput(root, runnerReference); + if (screenshotFile === runnerFile) throw new Error("page_evidence_output_collision: screenshot and runner paths must differ"); + await page.screenshot({ animations: "disabled", caret: "hide", path: screenshotFile }); + + const semanticEnvironment = await page.evaluate(({ scale }) => ({ + color_scheme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light", + locale: navigator.language, + page_scale_factor: scale, + reduced_motion: matchMedia("(prefers-reduced-motion: reduce)").matches ? "reduce" : "no-preference", + viewport: { height: window.innerHeight, width: window.innerWidth }, + }), { scale: pageScaleFactor }); + const browserVersion = page.context().browser()?.version() ?? "unknown"; + const runner = { + evidence: { artifacts: [{ media_type: "image/png", path: screenshotReference }], kind: "captured" }, + nonce: receipt.nonce, + receipt_sha256: crypto.createHash("sha256").update(receiptBytes).digest("hex"), + recorded_at: new Date().toISOString(), + record_kind: "page_evidence_runner_result", + repository: receipt.repository, + revision: receipt.revision, + run: { + attempt: receipt.attempt, + id: receipt.run_id, + repository: receipt.repository, + revision: receipt.revision, + source: process.env.GITHUB_ACTIONS === "true" ? "github_actions" : "local", + }, + scenario_id: scenarioId, + schema_version: "1.0", + semantic_environment: { + browser: `Chromium ${browserVersion}`, + browser_revision: process.env.PAGE_EVIDENCE_BROWSER_REVISION ?? browserVersion, + platform: `${process.platform}/${process.arch}`, + ...semanticEnvironment, + }, + session_id: receipt.session_id, + source_sha256: receipt.source.sha256, + status: "passed", + }; + fs.writeFileSync(runnerFile, `${JSON.stringify(runner, null, 2)}\n`, { flag: "wx" }); +} diff --git a/tests/helpers/render-consumer-reference.mjs b/tests/helpers/render-consumer-reference.mjs new file mode 100644 index 0000000..499c4a1 --- /dev/null +++ b/tests/helpers/render-consumer-reference.mjs @@ -0,0 +1,36 @@ +import { patterns, samples } from "../../scripts/pattern-data.mjs"; + +const CANONICAL_PATTERN = "card-grid"; + +function declarationsToCss(declarations) { + return declarations + .map(([selector, properties]) => `${selector}{${Object.entries(properties).map(([name, value]) => `${name}:${value}`).join(";")}}`) + .join("\n"); +} + +export function canonicalConsumerReference() { + const pattern = patterns.find(([name]) => name === CANONICAL_PATTERN); + if (!pattern) throw new Error(`canonical pattern ${CANONICAL_PATTERN} is unavailable`); + return { + css: declarationsToCss(pattern[6]), + html: samples[CANONICAL_PATTERN], + pattern: CANONICAL_PATTERN, + }; +} + +export async function renderConsumerReference(page, mutation = "none") { + const fixture = canonicalConsumerReference(); + const mutationCss = mutation === "hidden-layout" ? ".card_grid{display:none;gap:99999rem}" : ""; + const mutationHtml = mutation === "long-content-reflow" + ? fixture.html.replace("Design audit", `Design audit ${"unbroken".repeat(256)}`) + : fixture.html; + await page.setContent(`${mutationHtml}`); + return fixture; +} diff --git a/tests/snapshots/consumer-reference-card-grid.png b/tests/snapshots/consumer-reference-card-grid.png new file mode 100644 index 0000000..c251fb2 Binary files /dev/null and b/tests/snapshots/consumer-reference-card-grid.png differ