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 3530e5c..1557013 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -22,6 +22,7 @@ jobs: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: + fetch-depth: 0 persist-credentials: false - name: Setup Node @@ -47,6 +48,7 @@ jobs: 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 @@ -66,6 +68,23 @@ jobs: 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 @@ -113,6 +132,9 @@ jobs: 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 @@ -169,6 +191,13 @@ jobs: 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 @@ -201,6 +230,133 @@ 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 diff --git a/DOMAINS.md b/DOMAINS.md index 79b7312..38a31ab 100644 --- a/DOMAINS.md +++ b/DOMAINS.md @@ -25,7 +25,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/reference-profiles/index.md`, `design-engineering/reference-profiles/governed-local/index.md`, `design-engineering/reference-profiles/external-adaptation/index.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` | | Platform Guides | `platform-guides/index.md` | `platform-guides/apple-interaction.md` | ## Shared Non-Domain Infrastructure @@ -43,6 +43,8 @@ All five new domain leaves are independent method rewrites inspired by [emilkowa - 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. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index ee3d5dd..a9d801b 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 @@ -25,6 +26,10 @@ Use this file before editing repository documentation. It names which file is au | 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 | @@ -35,7 +40,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 @@ -101,15 +106,16 @@ 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, nonblocking status, calibration cardinality, and pending owner approval. | +| `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. | | `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 @@ -123,15 +129,17 @@ If evidence fails, restrict, maintain, or roll back the candidate. A consumed st ## 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`. -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. +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`. + +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 @@ -145,6 +153,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: diff --git a/README.md b/README.md index 5b6a224..ea2924c 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,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. | @@ -90,6 +91,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/contract.md b/consumer-reference/contract.md index 99ad61a..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,6 +53,8 @@ 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. 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/index.md b/consumer-reference/index.md index 25597c0..3f7d38b 100644 --- a/consumer-reference/index.md +++ b/consumer-reference/index.md @@ -13,6 +13,8 @@ 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. @@ -24,4 +26,4 @@ Out of scope: profiles, themes, components, browser runtime, decorative styling, ## 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/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/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/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 5858fdb..95823d5 100644 --- a/design-engineering/index.md +++ b/design-engineering/index.md @@ -11,6 +11,7 @@ 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 @@ -24,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/governed-local/editorial/evidence/button.evidence.json b/design-engineering/reference-profiles/governed-local/editorial/evidence/button.evidence.json index 0ce4bdb..d631fdb 100644 --- a/design-engineering/reference-profiles/governed-local/editorial/evidence/button.evidence.json +++ b/design-engineering/reference-profiles/governed-local/editorial/evidence/button.evidence.json @@ -17,7 +17,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -45,7 +45,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -98,14 +98,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -223,7 +223,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -249,7 +249,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -277,7 +277,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -330,14 +330,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -455,7 +455,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -481,7 +481,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -509,7 +509,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -562,14 +562,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -687,7 +687,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -715,7 +715,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -743,7 +743,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -796,14 +796,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -921,7 +921,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -946,7 +946,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -974,7 +974,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1027,14 +1027,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -1152,7 +1152,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -1177,7 +1177,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1205,7 +1205,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1258,14 +1258,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -1383,7 +1383,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -1410,7 +1410,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1438,7 +1438,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1491,14 +1491,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -1616,7 +1616,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -1643,7 +1643,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1671,7 +1671,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1724,14 +1724,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -1849,7 +1849,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -1876,7 +1876,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1904,7 +1904,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1957,14 +1957,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -2082,7 +2082,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -2111,7 +2111,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2139,7 +2139,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2192,14 +2192,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -2317,7 +2317,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -2345,7 +2345,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2373,7 +2373,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2426,14 +2426,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -2551,7 +2551,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -2579,7 +2579,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2607,7 +2607,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2660,14 +2660,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -2785,7 +2785,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -2815,7 +2815,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2843,7 +2843,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2896,14 +2896,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -3021,7 +3021,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -3047,7 +3047,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -3075,7 +3075,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -3128,14 +3128,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -3253,7 +3253,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -3279,7 +3279,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -3307,7 +3307,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -3360,14 +3360,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -3485,7 +3485,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, 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 index 3565d8e..f9faa17 100644 --- a/design-engineering/reference-profiles/governed-local/terminal/evidence/button.evidence.json +++ b/design-engineering/reference-profiles/governed-local/terminal/evidence/button.evidence.json @@ -17,7 +17,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -45,7 +45,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -98,14 +98,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -223,7 +223,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -249,7 +249,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -277,7 +277,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -330,14 +330,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -455,7 +455,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -481,7 +481,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -509,7 +509,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -562,14 +562,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -687,7 +687,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -715,7 +715,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -743,7 +743,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -796,14 +796,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -921,7 +921,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -946,7 +946,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -974,7 +974,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1027,14 +1027,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -1152,7 +1152,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -1177,7 +1177,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1205,7 +1205,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1258,14 +1258,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -1383,7 +1383,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -1410,7 +1410,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1438,7 +1438,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1491,14 +1491,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -1616,7 +1616,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -1643,7 +1643,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1671,7 +1671,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1724,14 +1724,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -1849,7 +1849,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -1876,7 +1876,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1904,7 +1904,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -1957,14 +1957,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -2082,7 +2082,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -2111,7 +2111,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2139,7 +2139,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2192,14 +2192,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -2317,7 +2317,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -2345,7 +2345,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2373,7 +2373,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2426,14 +2426,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -2551,7 +2551,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -2579,7 +2579,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2607,7 +2607,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2660,14 +2660,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -2785,7 +2785,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -2815,7 +2815,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2843,7 +2843,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -2896,14 +2896,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -3021,7 +3021,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -3047,7 +3047,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -3075,7 +3075,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -3128,14 +3128,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -3253,7 +3253,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, @@ -3279,7 +3279,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -3307,7 +3307,7 @@ "browser_revision": "chromium-1228", "container_image": "local-host:25.5.0", "kind": "browser", - "lockfile_sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e", + "lockfile_sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80", "node": "v22.23.1", "platform": "darwin/arm64", "playwright": "1.61.0", @@ -3360,14 +3360,14 @@ "sha256": "sha256:5fe9bf91ec1198441169dc1aa0773c9f4d278b2e345d111c453c34bd0c575d49" }, { - "byte_length": 59030, + "byte_length": 60458, "path": "package-lock.json", - "sha256": "sha256:f57973ff40f27c7f545f94e9529b61fcc95d8521662f7e3ea3a2ece392dfc86e" + "sha256": "sha256:d3f737b3aee4dffe053129c197ecc38393cd45123f0986d068655df34177ce80" }, { - "byte_length": 1404, + "byte_length": 1625, "path": "package.json", - "sha256": "sha256:d08d6dd5282c93788b1ec54a5a9fb9306f314a7357d5c915173141d5b0c84406" + "sha256": "sha256:0ce4b2d065b7b3ebdcf6ab818939bea8e1595b99515b00fbc72586b12a056146" }, { "byte_length": 532, @@ -3485,7 +3485,7 @@ "sha256": "sha256:51c09abb671b615d075ba74b8578efc500fc000d1e39a62d9fc20a76a27a5a46" } ], - "sha256": "sha256:b3c88210f186b8c662666f73187dd7bd1cb02012079096516cb27a3142fd80bf" + "sha256": "sha256:dfc234bdeae8e90ac33cc5724893b9235a0898c85d5aff7cea349da354e53406" }, "started_at": "2026-07-14T06:33:47.214Z" }, 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 255cab1..ca8d3ff 100644 --- a/index.md +++ b/index.md @@ -16,6 +16,7 @@ Primary role: OKF bundle map. - [Design Engineering](design-engineering/index.md) - Product-layer interface-craft decisions and verification questions. - [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. @@ -29,4 +30,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 index 2f7c7c8..c66f779 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,28 @@ "style-dictionary": "5.5.0" }, "devDependencies": { + "@axe-core/playwright": "4.12.1", "@playwright/test": "1.61.0", - "ajv": "8.20.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", @@ -509,6 +524,24 @@ "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", @@ -537,6 +570,16 @@ "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", diff --git a/package.json b/package.json index 7e766b1..2f0dc34 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,11 @@ "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", @@ -17,8 +19,10 @@ "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": "8.20.0", + "ajv-formats": "3.0.1" }, "dependencies": { "style-dictionary": "5.5.0" 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 2fd29eb..c0d88a1 100644 --- a/quality/evidence/executable-evidence.md +++ b/quality/evidence/executable-evidence.md @@ -25,6 +25,10 @@ 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. | 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 c5dd82f..d03795f 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,5 +49,6 @@ 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 fifth 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. | 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/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/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/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/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/governance-test-fixture.mjs b/scripts/governance-test-fixture.mjs index f710646..05f4f31 100644 --- a/scripts/governance-test-fixture.mjs +++ b/scripts/governance-test-fixture.mjs @@ -37,6 +37,8 @@ export const files = { "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, "", @@ -83,7 +85,9 @@ export const files = { " 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", @@ -103,10 +107,34 @@ export const files = { "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", @@ -146,6 +174,27 @@ export const files = { " 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", @@ -158,10 +207,43 @@ export const files = { "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", @@ -176,6 +258,10 @@ export const files = { "| 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 |", @@ -219,7 +305,11 @@ export const files = { "A stable contract must never be silently relabeled shared-experimental.", "", "## 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`.", + "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", 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/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/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-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-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-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.mjs b/scripts/test-validate-consumer-reference.mjs index 5391377..639b680 100644 --- a/scripts/test-validate-consumer-reference.mjs +++ b/scripts/test-validate-consumer-reference.mjs @@ -4,12 +4,17 @@ import fs from "node:fs"; import path from "node:path"; import { spawnSync } from "node:child_process"; 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(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", @@ -84,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]; }, @@ -162,7 +230,7 @@ const expectedSchemaRuleNames = [ ]; const runCase = makeConsumerReferenceCaseRunner({ baseItem, schema }); -const results = cases.map(runCase); +const results = [...cases.map(runCase), ...migrationReceiverCases.map(runMigrationReceiverCase)]; const failures = results .filter((result) => !result.ok) .map((result) => `missing_semantic:${result.name}:${result.expected}`); diff --git a/scripts/test-validate-domains.mjs b/scripts/test-validate-domains.mjs index 19cb3cd..1d63fa8 100644 --- a/scripts/test-validate-domains.mjs +++ b/scripts/test-validate-domains.mjs @@ -4,7 +4,7 @@ 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 { fileURLToPath, pathToFileURL } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const validator = path.join(root, "scripts", "validate-domains.mjs"); @@ -32,17 +32,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}`, - `source_repository: ${repository}`, - `source_path: ${sourcePath}`, - `source_revision: ${revision}`, + ]; + if (provenanceKind) frontmatter.push(`provenance_kind: ${provenanceKind}`); + if ((!provenanceKind && !omitSourceFields) || forgedSourceFields) { + frontmatter.push(`source_repository: ${repository}`, `source_path: ${sourcePath}`, `source_revision: ${revision}`); + } + const attribution = provenanceKind === "local" + ? ["- Local StyleGallery method; no upstream attribution."] + : [ + `- Upstream inspiration: ${repository}/blob/${revision}/${sourcePath}`, + `- Snapshot: \`${revision}\``, + "- Reuse form: independent method rewrite.", + ]; + return [ + ...frontmatter, "---", "", `# ${title}`, @@ -74,9 +85,7 @@ function leafPage({ title, domain, sourcePath, parent, next, lifecycle = "experi "", "## Source, License, And Attribution", "", - `- Upstream inspiration: ${repository}/blob/${revision}/${sourcePath}`, - `- Snapshot: \`${revision}\``, - "- Reuse form: independent method rewrite.", + ...attribution, "", "## IA Navigation", "", @@ -86,6 +95,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\n- [Layout](layout/index.md)\n- [Motion](motion/index.md)\n- [Design Engineering](design-engineering/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- [Platform Guides](platform-guides/index.md)\n", @@ -107,7 +133,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/reference-profiles/index.md`, `design-engineering/reference-profiles/governed-local/index.md`, `design-engineering/reference-profiles/external-adaptation/index.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` |", "| Platform Guides | `platform-guides/index.md` | `platform-guides/apple-interaction.md` |", "", `Source snapshot \`${revision}\`.`, @@ -131,8 +157,9 @@ const baseFiles = { "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"], ["Reference Profiles", "reference-profiles/index.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", @@ -155,6 +182,24 @@ const cases = [ { name: "missing_domain_index", omit: ["design-engineering/index.md"], expect: "design-engineering/index.md: missing file" }, { name: "missing_domain_leaf", omit: ["motion/vocabulary.md"], expect: "motion/vocabulary.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: "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" }, { name: "missing_lifecycle", mutate: ["motion/vocabulary.md", "lifecycle: experimental\n", ""], expect: "motion/vocabulary.md: missing lifecycle" }, @@ -182,13 +227,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; @@ -199,21 +242,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.exitCode = report.ok ? 0 : 1; diff --git a/scripts/test-validate-governance.mjs b/scripts/test-validate-governance.mjs index bfed51d..df54005 100644 --- a/scripts/test-validate-governance.mjs +++ b/scripts/test-validate-governance.mjs @@ -63,16 +63,114 @@ 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_consumer_reference_codeowner", mutate: { ".github/CODEOWNERS": files[".github/CODEOWNERS"].replace("/consumer-reference/ @changeroa\n", "") }, expect: ".github/CODEOWNERS: missing /consumer-reference/ @changeroa" }, diff --git a/scripts/test-validate-ia.mjs b/scripts/test-validate-ia.mjs index 3a283cf..f9c6449 100644 --- a/scripts/test-validate-ia.mjs +++ b/scripts/test-validate-ia.mjs @@ -35,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", "", @@ -68,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", @@ -77,15 +79,18 @@ 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- [Reference Profiles](reference-profiles/index.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", @@ -99,7 +104,7 @@ const baseFiles = { "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"), }; @@ -181,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: { 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-webpage-workflow.mjs b/scripts/test-validate-webpage-workflow.mjs index 1d34f17..f76fb4f 100644 --- a/scripts/test-validate-webpage-workflow.mjs +++ b/scripts/test-validate-webpage-workflow.mjs @@ -130,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 = [ @@ -186,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: { 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 d64cf1b..8813db1 100644 --- a/scripts/validate-consumer-reference.mjs +++ b/scripts/validate-consumer-reference.mjs @@ -2,6 +2,7 @@ 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"; @@ -10,6 +11,7 @@ import { validateReferenceProfile, validateReferenceProfileSet } from "./referen const root = process.cwd(); 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 = []; @@ -22,6 +24,7 @@ const defaultItems = [ ]; const reverseMarkers = ["consumerreference", "designengineeringreferenceprofiles", "profilejson", "tokensdtcgjson", "relatedfixturesetid"]; const profileSummaries = []; +let checkedMigrationRecords = 0; function addFailure(code, relative, message) { failures.push({ code, message, path: relative }); @@ -73,65 +76,91 @@ function parseArguments() { 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); } @@ -225,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; @@ -238,7 +281,7 @@ for (const finding of validateReferenceProfileSet(profileSummaries)) addFailure( 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, profiles: profileSummaries, 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")); diff --git a/scripts/validate-domains.mjs b/scripts/validate-domains.mjs index b530e43..b1face0 100644 --- a/scripts/validate-domains.mjs +++ b/scripts/validate-domains.mjs @@ -2,13 +2,10 @@ import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { collectDomainBoundaryFailures } from "./domain-document-boundaries.mjs"; import { isOmoDependency, markdownLinkDestinations, stripFencedCodeBlocks } 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}$/; @@ -18,25 +15,33 @@ const referenceDocuments = [ "design-engineering/reference-profiles/external-adaptation/index.md", ]; -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"]], + leaves: [ + { 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", leaves: [["platform-guides/apple-interaction.md", "skills/apple-design/SKILL.md"]] }, + { 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 requiredLeafSections = [ "Repository Boundary", @@ -120,7 +125,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), ...(domain.referenceDocuments ?? [])].sort(); + const expectedLeaves = [...domain.leaves.map((leaf) => leaf.path), ...(domain.referenceDocuments ?? [])].sort(); if (JSON.stringify(declaredLeaves) !== JSON.stringify(expectedLeaves)) valid = false; } } @@ -139,8 +144,8 @@ function checkIndex(domain) { 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.basename(leaf); + for (const leaf of domain.leaves) { + const target = path.basename(leaf.path); if (!content.match(new RegExp(`\\[[^\\]]+\\]\\(${target.replaceAll(".", "\\.")}\\)`))) { failures.push(`${relative}: missing leaf route ${target}`); } @@ -150,23 +155,36 @@ function checkIndex(domain) { } } -function checkLeaf(domain, relative, expectedSourcePath, titles) { +function checkLeaf(domain, leaf, titles) { + const { path: relative, provenance, sourcePath: expectedSourcePath } = leaf; const content = read(relative); if (!content) return; const metadata = parseFrontmatter(relative, content); - for (const field of ["type", "title", "description", "domain", "lifecycle", "source_repository", "source_path", "source_revision"]) { + 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") failures.push(`${relative}: external adaptation lifecycle must be experimental`); - if (metadata.source_repository && metadata.source_repository !== 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 !== revision) { - failures.push(`${relative}: unexpected source_revision ${metadata.source_revision}`); + if (metadata.lifecycle && metadata.lifecycle !== "experimental") { + failures.push(`${relative}: ${provenance === "local" ? "local leaf" : "external adaptation"} lifecycle must be experimental`); + } + if (provenance === "external") { + if (metadata.source_repository && metadata.source_repository !== 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 !== 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}`); + } } if (metadata.title) { if (titles.has(metadata.title)) failures.push(`${relative}: duplicate title ${metadata.title}`); @@ -214,24 +232,48 @@ function checkPromotionBoundary() { for (const clause of required) if (!content.includes(clause)) failures.push(`${relative}: missing promotion boundary ${clause}`); } -checkManifest(); -read("quality/claim-records/stylegallery-multidomain-scope.md"); -requireRootRoutes(); -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); +function boundaryRegistry(registry) { + return registry.map((domain) => ({ + ...domain, + leaves: domain.leaves.map((leaf) => [leaf.path, leaf.sourcePath]), + })); +} + +export function validateDomains({ root: nextRoot = process.cwd(), domains: nextDomains = canonicalDomains } = {}) { + const previousRoot = root; + const previousDomains = domains; + const previousFailures = failures; + root = nextRoot; + domains = nextDomains; + failures = []; + checkManifest(); + read("quality/claim-records/stylegallery-multidomain-scope.md"); + requireRootRoutes(); + const titles = new Set(); + let checkedLeaves = 0; + for (const domain of domains) { + checkIndex(domain); + for (const leaf of domain.leaves) { + checkedLeaves += 1; + checkLeaf(domain, leaf, titles); + } } + failures.push(...collectDomainBoundaryFailures(root, boundaryRegistry(domains))); + checkReferenceDocuments(); + checkPromotionBoundary(); + + const result = { ok: failures.length === 0, checkedDomains: domains.length, checkedLeaves, failures: [...new Set(failures)] }; + root = previousRoot; + domains = previousDomains; + failures = previousFailures; + return result; +} + +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; } -failures.push(...collectDomainBoundaryFailures(root, domains)); -checkReferenceDocuments(); -checkPromotionBoundary(); - -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.exitCode = result.ok ? 0 : 1; diff --git a/scripts/validate-governance.mjs b/scripts/validate-governance.mjs index 51b3279..67c0ca9 100644 --- a/scripts/validate-governance.mjs +++ b/scripts/validate-governance.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; 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"; @@ -53,6 +54,10 @@ function requireGovernanceMatrix() { "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", @@ -100,8 +105,6 @@ 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"); } @@ -209,6 +212,12 @@ 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(); diff --git a/scripts/validate-ia.mjs b/scripts/validate-ia.mjs index 62b9d51..64bf37a 100644 --- a/scripts/validate-ia.mjs +++ b/scripts/validate-ia.mjs @@ -37,6 +37,15 @@ const governedProfileLinks = [ "[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); @@ -81,6 +90,7 @@ function requireRootRoles() { 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() { 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-webpage-workflow.mjs b/scripts/validate-webpage-workflow.mjs index f0e7778..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)])], 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/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-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" }); +}