From f49d147b017976e942d6044d0d33ee22d9383eaf Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 2 Aug 2026 14:48:04 +0900 Subject: [PATCH] fix: harden CI evidence and removal contracts --- .gitea/workflows/quality-gates.yml | 18 +- config/ci/gates.json | 553 ++++++++++++++---- docs/operations/ci-quality-gates.md | 4 +- docs/security/supply-chain.md | 4 +- scripts/check-ci-contract.ts | 5 +- scripts/contracts/ci-gates.ts | 97 ++- scripts/generate-ci-workflow.ts | 12 +- scripts/lib/removal-fixture.ts | 233 ++++++++ scripts/run-ci-gate.ts | 50 ++ ...st-browser-file-storage-runtime-removal.ts | 304 +--------- scripts/test-optional-recipe-removal.ts | 80 +-- scripts/test-realtime-runtime-removal.ts | 297 +--------- scripts/test-sample-removal.ts | 77 ++- .../ci-contract/duplicate-gate-id.json | 2 +- .../ci-contract/missing-artifact-schema.json | 2 +- .../ci-workflow-generation.test.ts.snap | 18 +- tests/unit/ci-artifact-contract.test.ts | 7 +- tests/unit/ci-workflow-generation.test.ts | 119 +++- tests/unit/removal-fixture.test.ts | 54 ++ tests/unit/supply-chain.test.ts | 6 +- 20 files changed, 1175 insertions(+), 767 deletions(-) create mode 100644 scripts/lib/removal-fixture.ts create mode 100644 tests/unit/removal-fixture.test.ts diff --git a/.gitea/workflows/quality-gates.yml b/.gitea/workflows/quality-gates.yml index b0a0638..5df76a7 100644 --- a/.gitea/workflows/quality-gates.yml +++ b/.gitea/workflows/quality-gates.yml @@ -63,7 +63,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Install Playwright browsers if: ${{ matrix.browser }} run: corepack pnpm exec playwright install --with-deps chromium firefox webkit @@ -103,7 +103,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Install Playwright browsers if: ${{ matrix.browser }} run: corepack pnpm exec playwright install --with-deps chromium firefox webkit @@ -136,7 +136,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Build candidate once and verify local evidence run: corepack pnpm ci:gate -- FE-GATE-015 - name: Archive and validate the exact candidate file set @@ -192,7 +192,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Download release candidate uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 with: @@ -234,7 +234,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Download release candidate uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 with: @@ -278,7 +278,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Download release candidate uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 with: @@ -336,7 +336,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Run blocking gate run: corepack pnpm ci:gate -- ${{ matrix.gate }} - name: Upload production gate evidence @@ -366,7 +366,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Run blocking gate run: corepack pnpm ci:gate -- FE-GATE-018 - name: Upload field gate evidence @@ -392,7 +392,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Run documentation gate run: corepack pnpm ci:gate -- FE-GATE-017 - name: Upload documentation gate evidence diff --git a/config/ci/gates.json b/config/ci/gates.json index b67c174..3b53c81 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -648,527 +648,870 @@ { "id": "artifact-artifacts-quality-install-txt", "path": "artifacts/quality/install.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-quality-lint-txt", "path": "artifacts/quality/lint.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-quality-check-types-txt", "path": "artifacts/quality/check-types.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-quality-gates-FE-GATE-004-txt", "path": "artifacts/quality/gates/FE-GATE-004.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-tests-runtime-schema-xml", "path": "artifacts/tests/runtime-schema.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-runtime-schema" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-005-txt", "path": "artifacts/quality/gates/FE-GATE-005.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-tests-unit-xml", "path": "artifacts/tests/unit.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-unit" + ] }, { "id": "artifact-artifacts-tests-coverage-xml", "path": "artifacts/tests/coverage.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-coverage" + ] }, { "id": "artifact-artifacts-tests-coverage-coverage-summary-json", "path": "artifacts/tests/coverage/coverage-summary.json", - "schemaId": "json-coverage-summary-v8" + "schemaId": "json-coverage-summary-v8", + "production": "command-generated", + "producerCommandIds": [ + "test-coverage" + ] }, { "id": "artifact-artifacts-quality-risk-coverage-json", "path": "artifacts/quality/risk-coverage.json", - "schemaId": "json-risk-coverage-v3" + "schemaId": "json-risk-coverage-v3", + "production": "command-generated", + "producerCommandIds": [ + "test-coverage" + ] }, { "id": "artifact-artifacts-quality-risk-coverage-fixture-json", "path": "artifacts/quality/risk-coverage-fixture.json", - "schemaId": "json-risk-coverage-v3" + "schemaId": "json-risk-coverage-v3", + "production": "command-generated", + "producerCommandIds": [ + "check-coverage-fixture" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-006-txt", "path": "artifacts/quality/gates/FE-GATE-006.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-tests-component-xml", "path": "artifacts/tests/component.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-component" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-007-txt", "path": "artifacts/quality/gates/FE-GATE-007.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-tests-integration-xml", "path": "artifacts/tests/integration.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-integration" + ] }, { "id": "artifact-artifacts-tests-http-scenario-executions-json", "path": "artifacts/tests/http-scenario-executions.json", - "schemaId": "json-http-scenario-receipt" + "schemaId": "json-http-scenario-receipt", + "production": "command-generated", + "producerCommandIds": [ + "test-http-scenario-evidence" + ] }, { "id": "artifact-artifacts-quality-http-scenario-evidence-json", "path": "artifacts/quality/http-scenario-evidence.json", - "schemaId": "json-test-evidence-report" + "schemaId": "json-test-evidence-report", + "production": "command-generated", + "producerCommandIds": [ + "test-http-scenario-evidence" + ] }, { "id": "artifact-artifacts-quality-http-scenario-evidence-fixture-json", "path": "artifacts/quality/http-scenario-evidence-fixture.json", - "schemaId": "json-test-evidence-report" + "schemaId": "json-test-evidence-report", + "production": "command-generated", + "producerCommandIds": [ + "test-http-scenario-evidence" + ] }, { "id": "artifact-artifacts-tests-reference-feature-xml", "path": "artifacts/tests/reference-feature.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-reference-feature" + ] }, { "id": "artifact-artifacts-tests-optional-recipes-xml", "path": "artifacts/tests/optional-recipes.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-recipes" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-008-txt", "path": "artifacts/quality/gates/FE-GATE-008.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-tests-e2e-report-index-html", "path": "artifacts/tests/e2e/report/index.html", - "schemaId": "html" + "schemaId": "html", + "production": "command-generated", + "producerCommandIds": [ + "test-e2e" + ] }, { "id": "artifact-artifacts-tests-e2e-results-xml", "path": "artifacts/tests/e2e/results.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-e2e" + ] }, { "id": "artifact-artifacts-tests-browser-capabilities-report-index-html", "path": "artifacts/tests/browser-capabilities/report/index.html", - "schemaId": "html" + "schemaId": "html", + "production": "command-generated", + "producerCommandIds": [ + "test-browser-capabilities" + ] }, { "id": "artifact-artifacts-tests-browser-capabilities-results-xml", "path": "artifacts/tests/browser-capabilities/results.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-browser-capabilities" + ] }, { "id": "artifact-artifacts-tests-storybook-report-index-html", "path": "artifacts/tests/storybook/report/index.html", - "schemaId": "html" + "schemaId": "html", + "production": "command-generated", + "producerCommandIds": [ + "test-storybook" + ] }, { "id": "artifact-artifacts-tests-storybook-results-xml", "path": "artifacts/tests/storybook/results.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-storybook" + ] }, { "id": "artifact-artifacts-tests-visual-report-index-html", "path": "artifacts/tests/visual/report/index.html", - "schemaId": "html" + "schemaId": "html", + "production": "command-generated", + "producerCommandIds": [ + "test-visual" + ] }, { "id": "artifact-artifacts-tests-visual-results-xml", "path": "artifacts/tests/visual/results.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-visual" + ] }, { "id": "artifact-artifacts-quality-test-evidence-json", "path": "artifacts/quality/test-evidence.json", - "schemaId": "json-test-evidence-report" + "schemaId": "json-test-evidence-report", + "production": "command-generated", + "producerCommandIds": [ + "check-test-evidence-browser" + ] }, { "id": "artifact-artifacts-quality-test-evidence-fixture-json", "path": "artifacts/quality/test-evidence-fixture.json", - "schemaId": "json-test-evidence-report" + "schemaId": "json-test-evidence-report", + "production": "command-generated", + "producerCommandIds": [ + "check-test-evidence-fixture" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-009-txt", "path": "artifacts/quality/gates/FE-GATE-009.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-tests-a11y-json", "path": "artifacts/tests/a11y.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "test-a11y" + ] }, { "id": "artifact-artifacts-tests-a11y-manual-APP-HOME-md", "path": "artifacts/tests/a11y-manual/APP_HOME.md", - "schemaId": "markdown" + "schemaId": "markdown", + "production": "command-generated", + "producerCommandIds": [ + "review-a11y-manual" + ] }, { "id": "artifact-artifacts-tests-a11y-manual-EXAMPLES-UI-md", "path": "artifacts/tests/a11y-manual/EXAMPLES_UI.md", - "schemaId": "markdown" + "schemaId": "markdown", + "production": "command-generated", + "producerCommandIds": [ + "review-a11y-manual" + ] }, { "id": "artifact-artifacts-tests-a11y-manual-EXAMPLES-STATES-md", "path": "artifacts/tests/a11y-manual/EXAMPLES_STATES.md", - "schemaId": "markdown" + "schemaId": "markdown", + "production": "command-generated", + "producerCommandIds": [ + "review-a11y-manual" + ] }, { "id": "artifact-artifacts-tests-a11y-manual-EXAMPLES-AUTH-md", "path": "artifacts/tests/a11y-manual/EXAMPLES_AUTH.md", - "schemaId": "markdown" + "schemaId": "markdown", + "production": "command-generated", + "producerCommandIds": [ + "review-a11y-manual" + ] }, { "id": "artifact-artifacts-tests-a11y-manual-REFERENCE-RESOURCE-LIST-md", "path": "artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md", - "schemaId": "markdown" + "schemaId": "markdown", + "production": "command-generated", + "producerCommandIds": [ + "review-a11y-manual" + ] }, { "id": "artifact-artifacts-tests-a11y-manual-NOT-FOUND-md", "path": "artifacts/tests/a11y-manual/NOT_FOUND.md", - "schemaId": "markdown" + "schemaId": "markdown", + "production": "command-generated", + "producerCommandIds": [ + "review-a11y-manual" + ] }, { "id": "artifact-artifacts-tests-a11y-manual-report-json", "path": "artifacts/tests/a11y-manual/report.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "review-a11y-manual" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-010-txt", "path": "artifacts/quality/gates/FE-GATE-010.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-quality-dependency-report-json", "path": "artifacts/quality/dependency-report.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-architecture" + ] }, { "id": "artifact-artifacts-quality-design-system-json", "path": "artifacts/quality/design-system.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-design-system" + ] }, { "id": "artifact-artifacts-quality-design-system-fixture-json", "path": "artifacts/quality/design-system-fixture.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-design-system-fixture" + ] }, { "id": "artifact-artifacts-quality-i18n-json", "path": "artifacts/quality/i18n.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-i18n" + ] }, { "id": "artifact-artifacts-quality-i18n-fixture-json", "path": "artifacts/quality/i18n-fixture.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-i18n-fixture" + ] }, { "id": "artifact-artifacts-quality-diagnostics-json", "path": "artifacts/quality/diagnostics.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-diagnostics" + ] }, { "id": "artifact-artifacts-quality-diagnostics-fixture-json", "path": "artifacts/quality/diagnostics-fixture.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-diagnostics-fixture" + ] }, { "id": "artifact-artifacts-quality-realtime-boundaries-json", "path": "artifacts/quality/realtime-boundaries.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-realtime-boundaries" + ] }, { "id": "artifact-artifacts-quality-optional-recipes-json", "path": "artifacts/quality/optional-recipes.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-optional-recipes-source", + "check-optional-recipes" + ] }, { "id": "artifact-artifacts-quality-optional-recipe-fixtures-json", "path": "artifacts/quality/optional-recipe-fixtures.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-optional-recipe-fixtures" + ] }, { "id": "artifact-artifacts-quality-registries-json", "path": "artifacts/quality/registries.json", - "schemaId": "json-registry-snapshot" + "schemaId": "json-registry-snapshot", + "production": "command-generated", + "producerCommandIds": [ + "check-registries" + ] }, { "id": "artifact-artifacts-quality-registry-compatibility-fixtures-json", "path": "artifacts/quality/registry-compatibility-fixtures.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-registries-compatibility-fixtures" + ] }, { "id": "artifact-artifacts-quality-registry-baseline-fixture-json", "path": "artifacts/quality/registry-baseline-fixture.json", - "schemaId": "json-registry-governance-run" + "schemaId": "json-registry-governance-run", + "production": "command-generated", + "producerCommandIds": [ + "check-registries-baseline-fixture" + ] }, { "id": "artifact-artifacts-quality-registry-fixture-json", "path": "artifacts/quality/registry-fixture.json", - "schemaId": "json-registry-governance-run" + "schemaId": "json-registry-governance-run", + "production": "command-generated", + "producerCommandIds": [ + "check-registries-fixture" + ] }, { "id": "artifact-artifacts-quality-route-registry-fixture-json", "path": "artifacts/quality/route-registry-fixture.json", - "schemaId": "json-registry-governance-run" + "schemaId": "json-registry-governance-run", + "production": "command-generated", + "producerCommandIds": [ + "check-routes-fixture" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-011-txt", "path": "artifacts/quality/gates/FE-GATE-011.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-release-build-manifest-json", "path": "artifacts/release/build-manifest.json", - "schemaId": "json-build-manifest" + "schemaId": "json-build-manifest", + "production": "command-generated", + "producerCommandIds": [ + "build" + ] }, { "id": "artifact-artifacts-release-runtime-config-schema-json", "path": "artifacts/release/runtime-config.schema.json", - "schemaId": "json-schema-document" + "schemaId": "json-schema-document", + "production": "command-generated", + "producerCommandIds": [ + "build" + ] }, { "id": "artifact-artifacts-storybook-static-index-html", "path": "artifacts/storybook/static/index.html", - "schemaId": "html" + "schemaId": "html", + "production": "command-generated", + "producerCommandIds": [ + "build-storybook" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-012-txt", "path": "artifacts/quality/gates/FE-GATE-012.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-performance-bundle-json", "path": "artifacts/performance/bundle.json", - "schemaId": "json-bundle-performance" + "schemaId": "json-bundle-performance", + "production": "command-generated", + "producerCommandIds": [ + "check-bundle" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-013-txt", "path": "artifacts/quality/gates/FE-GATE-013.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-security-scan-sarif", "path": "artifacts/security/scan.sarif", - "schemaId": "sarif-secret-scan" + "schemaId": "sarif-secret-scan", + "production": "command-generated", + "producerCommandIds": [ + "build-release-candidate" + ] }, { "id": "artifact-artifacts-release-dependency-inventory-json", "path": "artifacts/release/dependency-inventory.json", - "schemaId": "json-dependency-inventory" + "schemaId": "json-dependency-inventory", + "production": "command-generated", + "producerCommandIds": [ + "build-release-candidate" + ] }, { "id": "artifact-artifacts-release-sbom-cdx-json", "path": "artifacts/release/sbom.cdx.json", - "schemaId": "json-sbom" + "schemaId": "json-sbom", + "production": "command-generated", + "producerCommandIds": [ + "build-release-candidate" + ] }, { "id": "artifact-artifacts-release-provenance-json", "path": "artifacts/release/provenance.json", - "schemaId": "json-provenance" + "schemaId": "json-provenance", + "production": "command-generated", + "producerCommandIds": [ + "build-release-candidate" + ] }, { "id": "artifact-artifacts-release-reproducible-build-json", "path": "artifacts/release/reproducible-build.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "verify-reproducible-build" + ] }, { "id": "artifact-artifacts-security-dependency-diff-json", "path": "artifacts/security/dependency-diff.json", - "schemaId": "json-dependency-diff" + "schemaId": "json-dependency-diff", + "production": "command-generated", + "producerCommandIds": [ + "build-release-candidate" + ] }, { "id": "artifact-artifacts-security-license-report-json", "path": "artifacts/security/license-report.json", - "schemaId": "json-license-report" + "schemaId": "json-license-report", + "production": "command-generated", + "producerCommandIds": [ + "build-release-candidate" + ] }, { "id": "artifact-artifacts-security-vulnerability-report-json", "path": "artifacts/security/vulnerability-report.json", - "schemaId": "json-vulnerability-report" + "schemaId": "json-vulnerability-report", + "production": "command-generated", + "producerCommandIds": [ + "build-release-candidate" + ] }, { "id": "artifact-artifacts-security-supply-chain-verification-json", "path": "artifacts/security/supply-chain-verification.json", - "schemaId": "json-supply-chain-verification" + "schemaId": "json-supply-chain-verification", + "production": "command-generated", + "producerCommandIds": [ + "build-release-candidate" + ] }, { "id": "artifact-artifacts-security-supply-chain-coherence-json", "path": "artifacts/security/supply-chain-coherence.json", - "schemaId": "json-supply-chain-coherence" + "schemaId": "json-supply-chain-coherence", + "production": "command-generated", + "producerCommandIds": [ + "build-release-candidate" + ] }, { "id": "artifact-artifacts-security-supply-chain-fixtures-json", "path": "artifacts/security/supply-chain-fixtures.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-supply-chain-fixtures" + ] }, { "id": "artifact-artifacts-security-supply-chain-provider-fixtures-json", "path": "artifacts/security/supply-chain-provider-fixtures.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "check-supply-chain-provider-fixtures" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-014-txt", "path": "artifacts/quality/gates/FE-GATE-014.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-release-compatibility-json", "path": "artifacts/release/compatibility.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "verify-compatibility" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-015-txt", "path": "artifacts/quality/gates/FE-GATE-015.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-release-release-candidate-json", "path": "artifacts/release/release-candidate.json", - "schemaId": "json-release-candidate" + "schemaId": "json-release-candidate", + "production": "command-generated", + "producerCommandIds": [ + "build-release-candidate" + ] }, { "id": "artifact-artifacts-release-verification-json", "path": "artifacts/release/verification.json", - "schemaId": "json-release-verification" + "schemaId": "json-release-verification", + "production": "command-generated", + "producerCommandIds": [ + "build-release-candidate" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-016-txt", "path": "artifacts/quality/gates/FE-GATE-016.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-runbooks-FE-RB-005-record-json", "path": "artifacts/runbooks/FE-RB-005/record.json", - "schemaId": "json-runbook-record" + "schemaId": "json-runbook-record", + "production": "command-generated", + "producerCommandIds": [ + "drill-runbook" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-017-txt", "path": "artifacts/quality/gates/FE-GATE-017.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-quality-documentation-review-json", "path": "artifacts/quality/documentation-review.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "verify-documentation" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-018-txt", "path": "artifacts/quality/gates/FE-GATE-018.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-performance-field-web-vitals-json", "path": "artifacts/performance/field-web-vitals.json", - "schemaId": "json-field-web-vitals" + "schemaId": "json-field-web-vitals", + "production": "command-generated", + "producerCommandIds": [ + "collect-web-vitals-evidence" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-019-txt", "path": "artifacts/quality/gates/FE-GATE-019.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-release-hosting-headers-json", "path": "artifacts/release/hosting-headers.json", - "schemaId": "json-generic-json-object" + "schemaId": "json-generic-json-object", + "production": "command-generated", + "producerCommandIds": [ + "verify-hosting-headers" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-020-txt", "path": "artifacts/quality/gates/FE-GATE-020.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-tests-sample-removal-xml", "path": "artifacts/tests/sample-removal.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-sample-removal" + ] }, { "id": "artifact-artifacts-tests-optional-recipe-removal-xml", "path": "artifacts/tests/optional-recipe-removal.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-optional-recipe-removal" + ] }, { "id": "artifact-artifacts-tests-browser-file-storage-runtime-removal-xml", "path": "artifacts/tests/browser-file-storage-runtime-removal.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-browser-file-storage-removal" + ] }, { "id": "artifact-artifacts-tests-realtime-runtime-removal-xml", "path": "artifacts/tests/realtime-runtime-removal.xml", - "schemaId": "junit" + "schemaId": "junit", + "production": "command-generated", + "producerCommandIds": [ + "test-realtime-removal" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-021-txt", "path": "artifacts/quality/gates/FE-GATE-021.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-runbooks-FE-RB-001-record-json", "path": "artifacts/runbooks/FE-RB-001/record.json", - "schemaId": "json-runbook-record" + "schemaId": "json-runbook-record", + "production": "command-generated", + "producerCommandIds": [ + "drill-runbook-2" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-022-txt", "path": "artifacts/quality/gates/FE-GATE-022.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-runbooks-FE-RB-002-record-json", "path": "artifacts/runbooks/FE-RB-002/record.json", - "schemaId": "json-runbook-record" + "schemaId": "json-runbook-record", + "production": "command-generated", + "producerCommandIds": [ + "drill-runbook-3" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-023-txt", "path": "artifacts/quality/gates/FE-GATE-023.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-runbooks-FE-RB-003-record-json", "path": "artifacts/runbooks/FE-RB-003/record.json", - "schemaId": "json-runbook-record" + "schemaId": "json-runbook-record", + "production": "command-generated", + "producerCommandIds": [ + "drill-runbook-4" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-024-txt", "path": "artifacts/quality/gates/FE-GATE-024.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-runbooks-FE-RB-004-record-json", "path": "artifacts/runbooks/FE-RB-004/record.json", - "schemaId": "json-runbook-record" + "schemaId": "json-runbook-record", + "production": "command-generated", + "producerCommandIds": [ + "drill-runbook-5" + ] }, { "id": "artifact-artifacts-quality-gates-FE-GATE-025-txt", "path": "artifacts/quality/gates/FE-GATE-025.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-quality-gates-FE-GATE-026-txt", "path": "artifacts/quality/gates/FE-GATE-026.txt", - "schemaId": "text" + "schemaId": "text", + "production": "runner-generated" }, { "id": "artifact-artifacts-performance-lab-json", "path": "artifacts/performance/lab.json", - "schemaId": "json-lab-performance" + "schemaId": "json-lab-performance", + "production": "command-generated", + "producerCommandIds": [ + "test-performance" + ] }, { "id": "artifact-artifacts-quality-ci-contract-json", "path": "artifacts/quality/ci-contract.json", - "schemaId": "json-ci-contract-report" + "schemaId": "json-ci-contract-report", + "production": "command-generated", + "producerCommandIds": [ + "check-ci" + ] } ], "gates": [ diff --git a/docs/operations/ci-quality-gates.md b/docs/operations/ci-quality-gates.md index 9f0ca96..34da475 100644 --- a/docs/operations/ci-quality-gates.md +++ b/docs/operations/ci-quality-gates.md @@ -56,8 +56,8 @@ Promotion job에는 build/rebuild command가 없으며 검증한 archive 자체 Provider baseline은 Gitea 1.26.4 이상과 Gitea Runner 1.0.0 이상이다. 이 workflow의 provider job은 Linux runner에서 실행 권한이 있는 -`/usr/bin/bwrap`를 필수로 요구하며, 사용할 Node 실행 파일도 sandbox 안의 -`/usr/local/bin/node`로 고정한다. Provider command는 bubblewrap 안에서 +`/usr/bin/bwrap`를 필수로 요구하며, trusted `process.execPath`를 sandbox 안의 +`/tmp/node`에 read-only bind한다. Provider command는 bubblewrap 안에서 `/bin/sh -eu -c`로 비대화식 실행되고 30분 안에 종료되어야 한다. Sandbox는 workspace를 read-only로 bind하고 `.git`을 가리며, 별도의 `untrusted` raw-evidence 하위 디렉터리만 writable로 노출한다. 따라서 command는 전달된 diff --git a/docs/security/supply-chain.md b/docs/security/supply-chain.md index ec8ecae..38c9da4 100644 --- a/docs/security/supply-chain.md +++ b/docs/security/supply-chain.md @@ -95,8 +95,8 @@ mandatory before any generated job becomes a required check. External provider supervision is fail-closed and requires a Linux runner with an executable `/usr/bin/bwrap`. Bubblewrap mounts the repository workspace -read-only, hides `.git`, pins the trusted Node executable at -`/usr/local/bin/node`, and exposes only the sibling `untrusted` raw-evidence +read-only, hides `.git`, and read-only binds the trusted `process.execPath` +inside the sandbox at `/tmp/node`. It exposes only the sibling `untrusted` raw-evidence directory as writable. Provider commands run non-interactively through `/bin/sh -eu -c`, receive a minimized environment plus only their own provider-prefixed credentials, and have a 30-minute limit. They must consume diff --git a/scripts/check-ci-contract.ts b/scripts/check-ci-contract.ts index fc949ab..d43d484 100644 --- a/scripts/check-ci-contract.ts +++ b/scripts/check-ci-contract.ts @@ -17,7 +17,10 @@ import { import { validatePackageScriptGraph } from "./lib/package-script-graph.ts"; import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts"; -const contract = await loadCiGateContract(process.cwd()); +const removalFixtureMode = process.argv.includes("--reduced-removal-fixture"); +const contract = await loadCiGateContract(process.cwd(), { + mode: removalFixtureMode ? "removal-fixture" : "canonical", +}); const index = indexCiGateContract(contract); const [packageDocument, nodeVersion] = await Promise.all([ readFile("package.json", "utf8").then((value) => JSON.parse(value) as { scripts?: Record }), diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts index dd65938..53ebf56 100644 --- a/scripts/contracts/ci-gates.ts +++ b/scripts/contracts/ci-gates.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import path from "node:path"; import { z } from "zod"; @@ -246,9 +247,18 @@ const artifactSchemaSchema = z.discriminatedUnion("kind", [ .strict(), ]); -const artifactSchema = z - .object({ id, path: repositoryPath, schemaId: id }) - .strict(); +const artifactBaseShape = { id, path: repositoryPath, schemaId: id } as const; +const artifactSchema = z.discriminatedUnion("production", [ + z.object({ ...artifactBaseShape, production: z.literal("source-controlled") }).strict(), + z + .object({ + ...artifactBaseShape, + production: z.literal("command-generated"), + producerCommandIds: z.array(id).min(1).max(32), + }) + .strict(), + z.object({ ...artifactBaseShape, production: z.literal("runner-generated") }).strict(), +]); const gateSchema = z .object({ @@ -405,6 +415,35 @@ export type CiGateContractIndex = Readonly<{ jobs: ReadonlyMap; retentionClasses: ReadonlyMap; }>; +export type LoadCiGateContractOptions = Readonly<{ + mode?: "canonical" | "removal-fixture"; +}>; + +const CANONICAL_GATE_SHAPE_SHA256 = + "a4a963d0b9deffb7a0a3d755bbbcb979d72610eb74751c3a2e5eca55251e12d4"; + +function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { + const normalized = gates.map( + ({ + id, + name, + commandIds, + logArtifactId, + evidenceArtifactIds, + retentionClassId, + requiresEnvironment, + }) => ({ + id, + name, + commandIds, + logArtifactId, + evidenceArtifactIds, + retentionClassId, + requiresEnvironment: requiresEnvironment ?? [], + }), + ); + return createHash("sha256").update(JSON.stringify(normalized)).digest("hex"); +} export function parseCiGateContract(value: unknown): CiGateContract { const result = ciGateContractSchema.safeParse(value); @@ -417,12 +456,22 @@ export function parseCiGateContract(value: unknown): CiGateContract { return result.data; } -export async function loadCiGateContract(root = process.cwd()): Promise { +export async function loadCiGateContract( + root = process.cwd(), + options: LoadCiGateContractOptions = {}, +): Promise { const [rawContract, rawPackage] = await Promise.all([ readFile(path.join(root, "config/ci/gates.json"), "utf8"), readFile(path.join(root, "package.json"), "utf8"), ]); const contract = parseCiGateContract(JSON.parse(rawContract)); + const mode = options.mode ?? "canonical"; + if ( + mode === "canonical" && + canonicalGateShapeSha256(contract.gates) !== CANONICAL_GATE_SHAPE_SHA256 + ) { + throw new TypeError("CI gate contract canonical gate semantic shape drift"); + } const packageDocument = z .object({ scripts: z.record(z.string(), z.string()).default({}) }) .passthrough() @@ -434,10 +483,23 @@ export async function loadCiGateContract(root = process.cwd()): Promise 0) { throw new TypeError(`CI gate contract missing package scripts: ${missing.join(", ")}`); } - const expectedCheckCi = "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && corepack pnpm check:ci-workflow"; + const expectedCheckCi = mode === "canonical" + ? "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && corepack pnpm check:ci-workflow" + : "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts --reduced-removal-fixture && corepack pnpm check:ci-workflow"; if (packageDocument.scripts["check:ci"] !== expectedCheckCi) { throw new TypeError("check:ci must use the exact canonical non-recursive orchestration"); } + const canonicalCheckCiDependencies = { + "check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check", + "check:ci-workflow": mode === "canonical" + ? "node scripts/generate-ci-workflow.ts --check" + : "node scripts/generate-ci-workflow.ts --check --reduced-removal-fixture", + } as const; + for (const [script, expected] of Object.entries(canonicalCheckCiDependencies)) { + if (packageDocument.scripts[script] !== expected) { + throw new TypeError(`canonical check:ci dependency drift: ${script}`); + } + } const graphFailures = validatePackageScriptGraph(packageDocument.scripts, "check:ci"); if (graphFailures.length > 0) { throw new TypeError(`CI package script graph invalid:\n${graphFailures.join("\n")}`); @@ -510,6 +572,16 @@ function validateContractSemantics( if (!schemaIds.has(artifact.schemaId)) { issue(`unknown artifact schema ${artifact.schemaId} for ${artifact.id}`); } + if (artifact.production === "command-generated") { + if (new Set(artifact.producerCommandIds).size !== artifact.producerCommandIds.length) { + issue(`duplicate producer command reference for artifact: ${artifact.id}`); + } + for (const producerCommandId of artifact.producerCommandIds) { + if (!commandIds.has(producerCommandId)) { + issue(`unknown producer command ${producerCommandId} for ${artifact.id}`); + } + } + } } for (const gate of contract.gates) { if (new Set(gate.commandIds).size !== gate.commandIds.length) { @@ -524,6 +596,21 @@ function validateContractSemantics( for (const artifactId of [gate.logArtifactId, ...gate.evidenceArtifactIds]) { if (!artifactIds.has(artifactId)) issue(`unknown artifact ${artifactId} for ${gate.id}`); } + const logArtifact = contract.artifacts.find(({ id }) => id === gate.logArtifactId); + if (logArtifact && logArtifact.production !== "runner-generated") { + issue(`gate log must be runner-generated: ${gate.id}`); + } + for (const artifactId of gate.evidenceArtifactIds) { + const artifact = contract.artifacts.find(({ id }) => id === artifactId); + if ( + artifact?.production === "command-generated" && + !artifact.producerCommandIds.some((producerCommandId) => + gate.commandIds.includes(producerCommandId) + ) + ) { + issue(`gate lacks a bound producer command for ${artifact.id}: ${gate.id}`); + } + } if (!retentionIds.has(gate.retentionClassId)) { issue(`unknown retention class ${gate.retentionClassId} for ${gate.id}`); } diff --git a/scripts/generate-ci-workflow.ts b/scripts/generate-ci-workflow.ts index f05b093..09baaba 100644 --- a/scripts/generate-ci-workflow.ts +++ b/scripts/generate-ci-workflow.ts @@ -28,6 +28,7 @@ export type GenerateCiWorkflowOptions = Readonly<{ root: string; contract?: CiGateContract; check: boolean; + contractMode?: "canonical" | "removal-fixture"; }>; export type GenerateCiWorkflowResult = Readonly<{ @@ -176,7 +177,7 @@ function renderStep( " - name: Frozen install", " run: |", " corepack enable", - " corepack pnpm install --frozen-lockfile", + " corepack pnpm install --frozen-lockfile --ignore-scripts", ]; case "browser-install": return [ @@ -335,7 +336,9 @@ export function createCiWorkflowGenerator( const createNonce = dependencies.createNonce ?? randomUUID; return async function generate(options: GenerateCiWorkflowOptions): Promise { const root = path.resolve(options.root); - const contract = options.contract ?? (await loadCiGateContract(root)); + const contract = options.contract ?? (await loadCiGateContract(root, { + mode: options.contractMode ?? "canonical", + })); const target = path.resolve(root, contract.providerAdapter); if (path.relative(root, target).startsWith("..") || path.relative(root, target) === "") { throw new TypeError(`workflow target escapes repository root: ${contract.providerAdapter}`); @@ -449,8 +452,11 @@ function hasErrorCode(error: unknown, code: string): boolean { const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isCli) { const check = process.argv.includes("--check"); + const contractMode = process.argv.includes("--reduced-removal-fixture") + ? "removal-fixture" as const + : "canonical" as const; try { - const result = await generateCiWorkflow({ root: process.cwd(), check }); + const result = await generateCiWorkflow({ root: process.cwd(), check, contractMode }); if (!result.matches) { process.stderr.write( `CI workflow drift: ${result.target} differs at byte ${result.firstDifferenceByte ?? 0}, line ${result.firstDifferenceLine ?? 1}\n`, diff --git a/scripts/lib/removal-fixture.ts b/scripts/lib/removal-fixture.ts new file mode 100644 index 0000000..3d9bf58 --- /dev/null +++ b/scripts/lib/removal-fixture.ts @@ -0,0 +1,233 @@ +import { spawnSync } from "node:child_process"; +import { + cp, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { + loadCiGateContract, + parseCiGateContract, +} from "../contracts/ci-gates.ts"; +import { generateCiWorkflow } from "../generate-ci-workflow.ts"; + +export const REMOVAL_FIXTURE_COPY_TARGETS = Object.freeze([ + "src", "tests", "recipes", "scripts", "schemas", "config", "public", + ".gitea", ".storybook", "index.html", "package.json", "tsconfig.base.json", + "tsconfig.json", "tsconfig.app.json", "tsconfig.node.json", "tsconfig.test.json", + "tsconfig.recipes.json", "tsconfig.web-worker.json", "tsconfig.service-worker.json", + "vite.service-worker.config.ts", "vite.config.ts", "vitest.config.ts", + "playwright.config.ts", "playwright.capabilities.config.ts", "playwright.dev.config.ts", + "playwright.storybook.config.ts", "playwright.visual.config.ts", "eslint.config.ts", + ".dependency-cruiser.json", ".nvmrc", +] as const); + +export function requireRemovalFixtureEnvironment(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required for removal verification`); + return value; +} + +export async function prepareRemovalFixture( + root: string, + copyTargets: readonly string[] = REMOVAL_FIXTURE_COPY_TARGETS, +): Promise { + await rm(root, { recursive: true, force: true }); + await mkdir(root, { recursive: true }); + for (const target of copyTargets) { + await cp(target, path.join(root, target), { recursive: true }); + } + await symlink(path.resolve("node_modules"), path.join(root, "node_modules"), "dir"); +} + +export function runRemovalFixturePnpm( + root: string, + pnpmCli: string, + script: string, + extra: readonly string[] = [], +): boolean { + return spawnSync(process.execPath, [pnpmCli, script, ...extra], { + cwd: root, + stdio: "inherit", + env: { ...process.env, CI_CONTRACT_MODE: "removal-fixture" }, + }).status === 0; +} + +export async function filesBelow(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + return (await Promise.all(entries.map((entry) => { + const target = path.join(directory, entry.name); + return entry.isDirectory() ? filesBelow(target) : [target]; + }))).flat(); +} + +function isWithin(target: string, root: string): boolean { + const relative = path.relative(root, target); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export async function runtimeImportGraph( + root: string, + runtimeSourceRoots: readonly string[], +): Promise> { + const files = (await filesBelow(root)) + .filter((file) => /\.(?:[cm]?ts|tsx)$/u.test(file)) + .map((file) => path.resolve(file)); + const sourceSet = new Set(files); + const runtimeRoots = runtimeSourceRoots.map((entry) => path.resolve(root, entry)); + const imports = new Map(); + for (const file of files) { + const source = await readFile(file, "utf8"); + const specifiers = [...source.matchAll(/(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu)] + .map((match) => match[1]) + .filter((specifier): specifier is string => typeof specifier === "string" && specifier.startsWith(".")); + imports.set(file, specifiers.map((specifier) => { + const base = path.resolve(path.dirname(file), specifier); + return [base, `${base}.ts`, `${base}.tsx`, `${base}.mts`, `${base}.cts`, path.join(base, "index.ts"), path.join(base, "index.tsx")] + .find((candidate) => sourceSet.has(candidate)) ?? base; + })); + } + const memo = new Map(); + const reachesRuntime = (file: string, visiting = new Set()): boolean => { + if (runtimeRoots.some((runtimeRoot) => isWithin(file, runtimeRoot))) return true; + const known = memo.get(file); + if (known !== undefined) return known; + if (visiting.has(file)) return false; + visiting.add(file); + const reaches = (imports.get(file) ?? []).some((dependency) => + runtimeRoots.some((runtimeRoot) => isWithin(dependency, runtimeRoot)) || + (sourceSet.has(dependency) && reachesRuntime(dependency, visiting)) + ); + visiting.delete(file); + memo.set(file, reaches); + return reaches; + }; + const testsRoot = path.resolve(root, "tests"); + return Object.freeze({ + dependentTests: Object.freeze(files.filter((file) => isWithin(file, testsRoot) && reachesRuntime(file))), + importingFiles: Object.freeze(files.filter((file) => + !runtimeRoots.some((runtimeRoot) => isWithin(file, runtimeRoot)) && + (imports.get(file) ?? []).some((dependency) => + runtimeRoots.some((runtimeRoot) => isWithin(dependency, runtimeRoot)) + ) + )), + }); +} + +export async function removeRuntimeDependentTests( + root: string, + runtimeSourceRoots: readonly string[], +): Promise { + const graph = await runtimeImportGraph(root, runtimeSourceRoots); + await Promise.all(graph.dependentTests.map((file) => rm(file, { force: true }))); + return graph.dependentTests.length; +} + +export async function assertNoRuntimeImports( + root: string, + runtimeSourceRoots: readonly string[], + capability: string, +): Promise { + const graph = await runtimeImportGraph(root, runtimeSourceRoots); + if (graph.importingFiles.length > 0) { + throw new Error(`Removed ${capability} runtime is still imported by: ${graph.importingFiles.map((file) => path.relative(root, file)).join(", ")}`); + } +} + +export function pruneScriptOrchestration( + scripts: Record, + orchestrationScript: string, + removedScripts: ReadonlySet, +): void { + const command = scripts[orchestrationScript]; + if (!command) return; + scripts[orchestrationScript] = command.split(" && ").filter((segment) => + ![...removedScripts].some((removed) => + new RegExp(`(?:^|\\s)(?:corepack\\s+)?pnpm\\s+${removed.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}(?:\\s|$)`, "u").test(segment) + ) + ).join(" && "); +} + +export async function regenerateRemovalFixtureWorkflow(root: string): Promise { + const contract = await loadCiGateContract(root, { mode: "removal-fixture" }); + await generateCiWorkflow({ root, contract, check: false }); +} + +export async function pruneRemovalFixtureCiContract(options: Readonly<{ + root: string; + removedScripts: ReadonlySet; + removedEvidencePathFragments: readonly string[]; +}>): Promise { + const packagePath = path.join(options.root, "package.json"); + const gatesPath = path.join(options.root, "config/ci/gates.json"); + const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as { + scripts: Record; + }; + for (const script of options.removedScripts) delete packageDocument.scripts[script]; + packageDocument.scripts["check:ci-workflow"] = + "node scripts/generate-ci-workflow.ts --check --reduced-removal-fixture"; + packageDocument.scripts["check:ci"] = + "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts --reduced-removal-fixture && corepack pnpm check:ci-workflow"; + + const contract = structuredClone( + parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))), + ); + const removedCommandIds = new Set( + contract.commands + .filter(({ script }) => options.removedScripts.has(script)) + .map(({ id }) => id), + ); + const missing = [...options.removedScripts].filter( + (script) => !contract.commands.some((command) => command.script === script), + ); + if (missing.length > 0) { + throw new Error(`removal fixture CI command set is incomplete: ${missing.join(", ")}`); + } + const removedArtifactIds = new Set( + contract.artifacts + .filter(({ path: artifactPath }) => + options.removedEvidencePathFragments.some((fragment) => artifactPath.includes(fragment)) + ) + .map(({ id }) => id), + ); + for (const fragment of options.removedEvidencePathFragments) { + if (!contract.artifacts.some(({ path: artifactPath }) => artifactPath.includes(fragment))) { + throw new Error(`removal fixture CI evidence is missing: ${fragment}`); + } + } + contract.commands = contract.commands.filter(({ id }) => !removedCommandIds.has(id)); + contract.artifacts = contract.artifacts + .filter(({ id }) => !removedArtifactIds.has(id)) + .map((artifact) => artifact.production === "command-generated" + ? { + ...artifact, + producerCommandIds: artifact.producerCommandIds.filter( + (commandId) => !removedCommandIds.has(commandId), + ), + } + : artifact) + .filter((artifact) => + artifact.production !== "command-generated" || artifact.producerCommandIds.length > 0 + ); + const retainedArtifactIds = new Set(contract.artifacts.map(({ id }) => id)); + for (const gate of contract.gates) { + gate.commandIds = gate.commandIds.filter((commandId) => !removedCommandIds.has(commandId)); + gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter((artifactId) => + retainedArtifactIds.has(artifactId) + ); + } + const referencedSchemaIds = new Set(contract.artifacts.map(({ schemaId }) => schemaId)); + contract.artifactSchemas = contract.artifactSchemas.filter(({ id }) => + referencedSchemaIds.has(id) + ); + const validated = parseCiGateContract(contract); + await Promise.all([ + writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`), + writeFile(gatesPath, `${JSON.stringify(validated, null, 2)}\n`), + ]); +} diff --git a/scripts/run-ci-gate.ts b/scripts/run-ci-gate.ts index dea544a..a451d4d 100644 --- a/scripts/run-ci-gate.ts +++ b/scripts/run-ci-gate.ts @@ -1,4 +1,6 @@ import { spawnSync } from "node:child_process"; +import { lstat } from "node:fs/promises"; +import path from "node:path"; import { ciCheckoutIdentityFailures, @@ -37,6 +39,28 @@ let passed = true; const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1_000; const MAX_STEP_OUTPUT_BYTES = 16 * 1024 * 1_024; const LOG_DIAGNOSTIC_RESERVE_BYTES = 4_096; +const freshlyProducedArtifactIds = new Set(); +const commandGeneratedEvidence = gate.evidenceArtifactIds + .map((artifactId) => contractIndex.artifacts.get(artifactId)) + .filter((artifact) => artifact?.production === "command-generated"); + +async function observeArtifactGeneration(relativePath: string): Promise { + try { + const metadata = await lstat(path.join(process.cwd(), relativePath), { + bigint: true, + }); + return [ + metadata.dev, + metadata.ino, + metadata.size, + metadata.mtimeNs, + metadata.ctimeNs, + ].join(":"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; + throw error; + } +} const appendOutput = (...values: readonly string[]): boolean => { for (const value of values.filter(Boolean)) { const addedBytes = Buffer.byteLength(value, "utf8") + 1; @@ -97,6 +121,17 @@ if (passed) { for (const commandId of gate.commandIds) { const step = contractIndex.commands.get(commandId); if (!step) throw new TypeError(`CI gate command disappeared after validation: ${commandId}`); + const producedArtifacts = commandGeneratedEvidence.filter((artifact) => + artifact.producerCommandIds.includes(commandId) + ); + const generationBefore = new Map( + await Promise.all( + producedArtifacts.map(async (artifact) => [ + artifact.id, + await observeArtifactGeneration(artifact.path), + ] as const), + ), + ); const commandLine = `$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim(); if (!appendOutput(commandLine) || logSchema.maxBytes - outputBytes <= LOG_DIAGNOSTIC_RESERVE_BYTES) { appendOutput("gate aggregate output budget exhausted before command execution"); @@ -159,6 +194,21 @@ if (passed) { passed = false; break; } + for (const artifact of producedArtifacts) { + const generationAfter = await observeArtifactGeneration(artifact.path); + if (generationAfter !== generationBefore.get(artifact.id)) { + freshlyProducedArtifactIds.add(artifact.id); + } + } + } +} + +if (passed) { + for (const artifact of commandGeneratedEvidence) { + if (!freshlyProducedArtifactIds.has(artifact.id)) { + appendOutput(`command-generated evidence was not freshly produced: ${artifact.path}`); + passed = false; + } } } diff --git a/scripts/test-browser-file-storage-runtime-removal.ts b/scripts/test-browser-file-storage-runtime-removal.ts index 68d5f1a..d042b89 100644 --- a/scripts/test-browser-file-storage-runtime-removal.ts +++ b/scripts/test-browser-file-storage-runtime-removal.ts @@ -1,22 +1,25 @@ -import { spawnSync } from "node:child_process"; import { - cp, mkdir, readFile, - readdir, rm, - symlink, writeFile, } from "node:fs/promises"; import path from "node:path"; -import { parseCiGateContract } from "./contracts/ci-gates.ts"; -import { generateCiWorkflow } from "./generate-ci-workflow.ts"; +import { + assertNoRuntimeImports, + prepareRemovalFixture, + pruneRemovalFixtureCiContract, + regenerateRemovalFixtureWorkflow, + removeRuntimeDependentTests, + requireRemovalFixtureEnvironment, + runRemovalFixturePnpm, +} from "./lib/removal-fixture.ts"; const fixtureRoot = path.resolve( ".tmp/browser-file-storage-runtime-removal", ); -const pnpmCli = requireEnvironment("npm_execpath"); +const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath"); const runtimePaths = [ "src/application/ports/browser-file-storage", "src/application/ports/browser-transfer", @@ -42,216 +45,14 @@ const removedEvidencePathFragments = [ "browser-capabilities", "browser-file-storage-runtime-removal", ] as const; -const copyTargets = [ - "src", - "tests", - "recipes", - "scripts", - "config", - "schemas", - "public", - ".gitea", - ".storybook", - "index.html", - "package.json", - "tsconfig.base.json", - "tsconfig.json", - "tsconfig.app.json", - "tsconfig.node.json", - "tsconfig.test.json", - "tsconfig.recipes.json", - "tsconfig.web-worker.json", - "tsconfig.service-worker.json", - "vite.service-worker.config.ts", - "vite.config.ts", - "vitest.config.ts", - "playwright.config.ts", - "playwright.capabilities.config.ts", - "playwright.dev.config.ts", - "playwright.storybook.config.ts", - "playwright.visual.config.ts", - "eslint.config.ts", - ".dependency-cruiser.json", - ".nvmrc", -] as const; - -function requireEnvironment(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`${name} is required for runtime removal verification`); - } - return value; -} - function runPnpm(script: string): boolean { - return ( - spawnSync(process.execPath, [pnpmCli, script], { - cwd: fixtureRoot, - stdio: "inherit", - }).status === 0 - ); + return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script); } -async function sourceFiles(directory: string): Promise { - const entries = await readdir(directory, { withFileTypes: true }); - return ( - await Promise.all( - entries.map(async (entry): Promise => { - const target = path.join(directory, entry.name); - if (entry.isDirectory()) { - return await sourceFiles(target); - } - return /\.(?:[cm]?ts|tsx)$/u.test(entry.name) - ? [path.resolve(target)] - : []; - }), - ) - ).flat(); -} - -function staticImportSpecifiers(source: string): string[] { - return [ - ...source.matchAll( - /(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu, - ), - ] - .map((match) => match[1]) - .filter((specifier): specifier is string => - typeof specifier === "string", - ); -} - -function isWithin(target: string, root: string): boolean { - const relative = path.relative(root, target); - return ( - relative === "" || - (!relative.startsWith("..") && !path.isAbsolute(relative)) - ); -} - -function resolvedImport( - importer: string, - specifier: string, - sourceSet: ReadonlySet, -): string | null { - if (!specifier.startsWith(".")) return null; - const base = path.resolve(path.dirname(importer), specifier); - const candidates = [ - base, - `${base}.ts`, - `${base}.tsx`, - `${base}.mts`, - `${base}.cts`, - path.join(base, "index.ts"), - path.join(base, "index.tsx"), - ]; - return candidates.find((candidate) => sourceSet.has(candidate)) ?? base; -} - -async function runtimeImportGraph(root: string): Promise> { - const files = await sourceFiles(root); - const sourceSet = new Set(files); - const runtimeRoots = runtimeSourceRoots.map((entry) => - path.resolve(root, entry), - ); - const imports = new Map(); - for (const file of files) { - const source = await readFile(file, "utf8"); - imports.set( - file, - staticImportSpecifiers(source) - .map((specifier) => - resolvedImport(file, specifier, sourceSet), - ) - .filter((target): target is string => target !== null), - ); - } - - const memo = new Map(); - const reachesRuntime = ( - file: string, - visiting = new Set(), - ): boolean => { - if (runtimeRoots.some((root) => isWithin(file, root))) return true; - const known = memo.get(file); - if (known !== undefined) return known; - if (visiting.has(file)) return false; - visiting.add(file); - const reaches = (imports.get(file) ?? []).some( - (dependency) => - runtimeRoots.some((runtimeRoot) => - isWithin(dependency, runtimeRoot), - ) || - (sourceSet.has(dependency) && - reachesRuntime(dependency, visiting)), - ); - visiting.delete(file); - memo.set(file, reaches); - return reaches; - }; - - const testsRoot = path.resolve(root, "tests"); - const dependentTests = files.filter( - (file) => isWithin(file, testsRoot) && reachesRuntime(file), - ); - const importingFiles = files.filter( - (file) => - !runtimeRoots.some((runtimeRoot) => - isWithin(file, runtimeRoot), - ) && - (imports.get(file) ?? []).some((dependency) => - runtimeRoots.some((runtimeRoot) => - isWithin(dependency, runtimeRoot), - ), - ), - ); - return Object.freeze({ - dependentTests: Object.freeze(dependentTests), - importingFiles: Object.freeze(importingFiles), - }); -} - -async function assertNoRuntimeImports(root: string): Promise { - const graph = await runtimeImportGraph(root); - if (graph.importingFiles.length > 0) { - throw new Error( - `Removed browser file/storage runtime is still imported by: ${graph.importingFiles - .map((file) => path.relative(root, file)) - .join(", ")}`, - ); - } -} - -async function removeRuntimeDependentTests( - root: string, -): Promise { - const graph = await runtimeImportGraph(root); - await Promise.all( - graph.dependentTests.map(async (file) => { - if (isWithin(file, path.resolve(root, "tests"))) { - await rm(file, { force: true }); - } - }), - ); - return graph.dependentTests.length; -} - -await rm(fixtureRoot, { recursive: true, force: true }); -await mkdir(fixtureRoot, { recursive: true }); -for (const target of copyTargets) { - await cp(target, path.join(fixtureRoot, target), { recursive: true }); -} -await symlink( - path.resolve("node_modules"), - path.join(fixtureRoot, "node_modules"), - "dir", -); +await prepareRemovalFixture(fixtureRoot); const removedRuntimeTests = - await removeRuntimeDependentTests(fixtureRoot); + await removeRuntimeDependentTests(fixtureRoot, runtimeSourceRoots); for (const runtimePath of runtimePaths) { await rm(path.join(fixtureRoot, runtimePath), { recursive: true, @@ -290,17 +91,6 @@ if (removedRuntimeEntries !== 3) { } await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); -const packagePath = path.join(fixtureRoot, "package.json"); -const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as { - scripts: Record; -}; -for (const script of removedScripts) { - delete packageDocument.scripts[script]; -} -await writeFile( - packagePath, - `${JSON.stringify(packageDocument, null, 2)}\n`, -); await rm(path.join(fixtureRoot, "playwright.capabilities.config.ts"), { force: true, }); @@ -330,67 +120,17 @@ await rm( { force: true }, ); -const gatesPath = path.join(fixtureRoot, "config/ci/gates.json"); -const gatesDocument = structuredClone( - parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))), -); -const removedCommandIds = new Set( - gatesDocument.commands - .filter(({ script }) => removedScripts.has(script)) - .map(({ id }) => id), -); -if (removedCommandIds.size !== removedScripts.size) { - throw new Error("Browser file/storage CI command removal set is incomplete"); -} -const removedArtifactIds = new Set( - gatesDocument.artifacts - .filter(({ path: artifactPath }) => - removedEvidencePathFragments.some((fragment) => - artifactPath.includes(fragment), - ), - ) - .map(({ id }) => id), -); -for (const fragment of removedEvidencePathFragments) { - if ( - !gatesDocument.artifacts.some(({ path: artifactPath }) => - artifactPath.includes(fragment), - ) - ) { - throw new Error(`Browser file/storage CI evidence is missing: ${fragment}`); - } -} -gatesDocument.commands = gatesDocument.commands.filter( - ({ id }) => !removedCommandIds.has(id), -); -gatesDocument.artifacts = gatesDocument.artifacts.filter( - ({ id }) => !removedArtifactIds.has(id), -); -for (const gate of gatesDocument.gates) { - gate.commandIds = gate.commandIds.filter( - (commandId) => !removedCommandIds.has(commandId), - ); - gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter( - (artifactId) => !removedArtifactIds.has(artifactId), - ); -} -const referencedSchemaIds = new Set( - gatesDocument.artifacts.map(({ schemaId }) => schemaId), -); -gatesDocument.artifactSchemas = gatesDocument.artifactSchemas.filter( - ({ id }) => referencedSchemaIds.has(id), -); -const validatedGates = parseCiGateContract(gatesDocument); -await writeFile( - gatesPath, - `${JSON.stringify(validatedGates, null, 2)}\n`, -); -await generateCiWorkflow({ +await pruneRemovalFixtureCiContract({ root: fixtureRoot, - contract: validatedGates, - check: false, + removedScripts, + removedEvidencePathFragments, }); -await assertNoRuntimeImports(fixtureRoot); +await regenerateRemovalFixtureWorkflow(fixtureRoot); +await assertNoRuntimeImports( + fixtureRoot, + runtimeSourceRoots, + "browser file/storage", +); const checks: Array = [ ["typecheck", runPnpm("check:types")], diff --git a/scripts/test-optional-recipe-removal.ts b/scripts/test-optional-recipe-removal.ts index 3fa1cee..3db120f 100644 --- a/scripts/test-optional-recipe-removal.ts +++ b/scripts/test-optional-recipe-removal.ts @@ -1,17 +1,23 @@ -import { spawnSync } from "node:child_process"; import { - cp, mkdir, readFile, - readdir, rm, - symlink, writeFile, } from "node:fs/promises"; import path from "node:path"; +import { + filesBelow, + prepareRemovalFixture, + pruneRemovalFixtureCiContract, + pruneScriptOrchestration, + regenerateRemovalFixtureWorkflow, + requireRemovalFixtureEnvironment, + runRemovalFixturePnpm, +} from "./lib/removal-fixture.ts"; + const fixtureRoot = path.resolve(".tmp/optional-recipe-removal"); -const pnpmCli = requireEnvironment("npm_execpath"); +const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath"); const copyTargets = [ "src", "tests", @@ -44,51 +50,55 @@ const copyTargets = [ ".nvmrc", ]; -function requireEnvironment(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`${name} is required to run removal verification`); - } - return value; -} - function runPnpm(script: string): boolean { - return ( - spawnSync(process.execPath, [pnpmCli, script], { - cwd: fixtureRoot, - stdio: "inherit", - }).status === 0 - ); + return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script); } -async function filesBelow(directory: string): Promise { - const entries = await readdir(directory, { withFileTypes: true }); - const groups = await Promise.all( - entries.map((entry) => { - const target = path.join(directory, entry.name); - return entry.isDirectory() ? filesBelow(target) : [target]; - }), - ); - return groups.flat(); +await prepareRemovalFixture(fixtureRoot, copyTargets); +for (const rootOnlyTest of [ + "tests/unit/ci-workflow-generation.test.ts", + "tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap", +]) { + await rm(path.join(fixtureRoot, rootOnlyTest), { force: true }); } - -await rm(fixtureRoot, { recursive: true, force: true }); -await mkdir(fixtureRoot, { recursive: true }); -for (const target of copyTargets) { - await cp(target, path.join(fixtureRoot, target), { recursive: true }); -} -await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir"); await rm(path.join(fixtureRoot, "recipes"), { recursive: true, force: true }); await rm(path.join(fixtureRoot, "tests/recipes"), { recursive: true, force: true, }); +const removedCiScripts = new Set([ + "check:types:recipes", + "test:recipes", + "check:optional-recipes", + "check:optional-recipes:source", + "check:optional-recipe-fixtures", + "test:optional-recipe-removal", +]); +const fixturePackagePath = path.join(fixtureRoot, "package.json"); +const fixturePackage = JSON.parse(await readFile(fixturePackagePath, "utf8")) as { + scripts: Record; +}; +pruneScriptOrchestration(fixturePackage.scripts, "check:types", removedCiScripts); +pruneScriptOrchestration(fixturePackage.scripts, "test:all", removedCiScripts); +await writeFile(fixturePackagePath, `${JSON.stringify(fixturePackage, null, 2)}\n`); +await pruneRemovalFixtureCiContract({ + root: fixtureRoot, + removedScripts: removedCiScripts, + removedEvidencePathFragments: [ + "optional-recipes", + "optional-recipe-fixtures", + "optional-recipe-removal", + ], +}); +await regenerateRemovalFixtureWorkflow(fixtureRoot); + const checks: Array<[string, boolean]> = [ ["typecheck", runPnpm("check:types")], ["architecture", runPnpm("check:architecture")], ["test", runPnpm("test:all")], ["build", runPnpm("build")], + ["ci-contract", runPnpm("check:ci")], ]; const residue: string[] = []; for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) { diff --git a/scripts/test-realtime-runtime-removal.ts b/scripts/test-realtime-runtime-removal.ts index 3d18b02..309d219 100644 --- a/scripts/test-realtime-runtime-removal.ts +++ b/scripts/test-realtime-runtime-removal.ts @@ -1,20 +1,23 @@ -import { spawnSync } from "node:child_process"; import { - cp, mkdir, readFile, - readdir, rm, - symlink, writeFile, } from "node:fs/promises"; import path from "node:path"; -import { parseCiGateContract } from "./contracts/ci-gates.ts"; -import { generateCiWorkflow } from "./generate-ci-workflow.ts"; +import { + assertNoRuntimeImports, + prepareRemovalFixture, + pruneRemovalFixtureCiContract, + regenerateRemovalFixtureWorkflow, + removeRuntimeDependentTests, + requireRemovalFixtureEnvironment, + runRemovalFixturePnpm, +} from "./lib/removal-fixture.ts"; const fixtureRoot = path.resolve(".tmp/realtime-runtime-removal"); -const pnpmCli = requireEnvironment("npm_execpath"); +const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath"); const runtimePaths = [ "src/application/ports/realtime", "src/application/ports/out/web-push-control.ts", @@ -38,212 +41,14 @@ const removedEvidencePathFragments = [ "realtime-boundaries", "realtime-runtime-removal", ] as const; -const copyTargets = [ - "src", - "tests", - "recipes", - "scripts", - "config", - "schemas", - "public", - ".gitea", - ".storybook", - "index.html", - "package.json", - "tsconfig.base.json", - "tsconfig.json", - "tsconfig.app.json", - "tsconfig.node.json", - "tsconfig.test.json", - "tsconfig.recipes.json", - "tsconfig.web-worker.json", - "tsconfig.service-worker.json", - "vite.service-worker.config.ts", - "vite.config.ts", - "vitest.config.ts", - "playwright.config.ts", - "playwright.capabilities.config.ts", - "playwright.dev.config.ts", - "playwright.storybook.config.ts", - "playwright.visual.config.ts", - "eslint.config.ts", - ".dependency-cruiser.json", - ".nvmrc", -] as const; - -function requireEnvironment(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`${name} is required for runtime removal verification`); - } - return value; -} - function runPnpm(script: string): boolean { - return ( - spawnSync(process.execPath, [pnpmCli, script], { - cwd: fixtureRoot, - stdio: "inherit", - }).status === 0 - ); + return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script); } -async function sourceFiles(directory: string): Promise { - const entries = await readdir(directory, { withFileTypes: true }); - return ( - await Promise.all( - entries.map(async (entry): Promise => { - const target = path.join(directory, entry.name); - if (entry.isDirectory()) return await sourceFiles(target); - return /\.(?:[cm]?ts|tsx)$/u.test(entry.name) - ? [path.resolve(target)] - : []; - }), - ) - ).flat(); -} - -function staticImportSpecifiers(source: string): string[] { - return [ - ...source.matchAll( - /(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu, - ), - ] - .map((match) => match[1]) - .filter((specifier): specifier is string => - typeof specifier === "string", - ); -} - -function isWithin(target: string, root: string): boolean { - const relative = path.relative(root, target); - return ( - relative === "" || - (!relative.startsWith("..") && !path.isAbsolute(relative)) - ); -} - -function resolvedImport( - importer: string, - specifier: string, - sourceSet: ReadonlySet, -): string | null { - if (!specifier.startsWith(".")) return null; - const base = path.resolve(path.dirname(importer), specifier); - const candidates = [ - base, - `${base}.ts`, - `${base}.tsx`, - `${base}.mts`, - `${base}.cts`, - path.join(base, "index.ts"), - path.join(base, "index.tsx"), - ]; - return candidates.find((candidate) => sourceSet.has(candidate)) ?? base; -} - -async function runtimeImportGraph(root: string): Promise> { - const files = await sourceFiles(root); - const sourceSet = new Set(files); - const runtimeRoots = runtimeSourceRoots.map((entry) => - path.resolve(root, entry), - ); - const imports = new Map(); - for (const file of files) { - const source = await readFile(file, "utf8"); - imports.set( - file, - staticImportSpecifiers(source) - .map((specifier) => resolvedImport(file, specifier, sourceSet)) - .filter((target): target is string => target !== null), - ); - } - - const memo = new Map(); - const reachesRuntime = ( - file: string, - visiting = new Set(), - ): boolean => { - if (runtimeRoots.some((root) => isWithin(file, root))) return true; - const known = memo.get(file); - if (known !== undefined) return known; - if (visiting.has(file)) return false; - visiting.add(file); - const reaches = (imports.get(file) ?? []).some( - (dependency) => - runtimeRoots.some((runtimeRoot) => - isWithin(dependency, runtimeRoot), - ) || - (sourceSet.has(dependency) && - reachesRuntime(dependency, visiting)), - ); - visiting.delete(file); - memo.set(file, reaches); - return reaches; - }; - - const testsRoot = path.resolve(root, "tests"); - return Object.freeze({ - dependentTests: Object.freeze( - files.filter( - (file) => isWithin(file, testsRoot) && reachesRuntime(file), - ), - ), - importingFiles: Object.freeze( - files.filter( - (file) => - !runtimeRoots.some((runtimeRoot) => - isWithin(file, runtimeRoot), - ) && - (imports.get(file) ?? []).some((dependency) => - runtimeRoots.some((runtimeRoot) => - isWithin(dependency, runtimeRoot), - ), - ), - ), - ), - }); -} - -async function removeRuntimeDependentTests(root: string): Promise { - const graph = await runtimeImportGraph(root); - await Promise.all( - graph.dependentTests.map(async (file) => { - if (isWithin(file, path.resolve(root, "tests"))) { - await rm(file, { force: true }); - } - }), - ); - return graph.dependentTests.length; -} - -async function assertNoRuntimeImports(root: string): Promise { - const graph = await runtimeImportGraph(root); - if (graph.importingFiles.length > 0) { - throw new Error( - `Removed realtime runtime is still imported by: ${graph.importingFiles - .map((file) => path.relative(root, file)) - .join(", ")}`, - ); - } -} - -await rm(fixtureRoot, { recursive: true, force: true }); -await mkdir(fixtureRoot, { recursive: true }); -for (const target of copyTargets) { - await cp(target, path.join(fixtureRoot, target), { recursive: true }); -} -await symlink( - path.resolve("node_modules"), - path.join(fixtureRoot, "node_modules"), - "dir", -); +await prepareRemovalFixture(fixtureRoot); const removedRuntimeTests = - await removeRuntimeDependentTests(fixtureRoot); + await removeRuntimeDependentTests(fixtureRoot, runtimeSourceRoots); for (const runtimePath of runtimePaths) { await rm(path.join(fixtureRoot, runtimePath), { recursive: true, @@ -280,17 +85,6 @@ if (!realtimeRecipe || !Object.hasOwn(realtimeRecipe, "referenceRuntime")) { delete realtimeRecipe.referenceRuntime; await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); -const packagePath = path.join(fixtureRoot, "package.json"); -const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as { - scripts: Record; -}; -for (const script of runtimeScripts) { - delete packageDocument.scripts[script]; -} -await writeFile( - packagePath, - `${JSON.stringify(packageDocument, null, 2)}\n`, -); for (const scriptPath of [ "scripts/check-realtime-boundaries.ts", "scripts/check-realtime-boundary-fixtures.ts", @@ -313,68 +107,13 @@ await rm( { force: true }, ); -const gatesPath = path.join(fixtureRoot, "config/ci/gates.json"); -const gatesDocument = structuredClone( - parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))), -); -const removedScripts = new Set(runtimeScripts); -const removedCommandIds = new Set( - gatesDocument.commands - .filter(({ script }) => removedScripts.has(script)) - .map(({ id }) => id), -); -if (removedCommandIds.size !== runtimeScripts.length) { - throw new Error("Realtime CI command removal set is incomplete"); -} -const removedArtifactIds = new Set( - gatesDocument.artifacts - .filter(({ path: artifactPath }) => - removedEvidencePathFragments.some((fragment) => - artifactPath.includes(fragment), - ), - ) - .map(({ id }) => id), -); -for (const fragment of removedEvidencePathFragments) { - if ( - !gatesDocument.artifacts.some(({ path: artifactPath }) => - artifactPath.includes(fragment), - ) - ) { - throw new Error(`Realtime CI evidence is missing: ${fragment}`); - } -} -gatesDocument.commands = gatesDocument.commands.filter( - ({ id }) => !removedCommandIds.has(id), -); -gatesDocument.artifacts = gatesDocument.artifacts.filter( - ({ id }) => !removedArtifactIds.has(id), -); -for (const gate of gatesDocument.gates) { - gate.commandIds = gate.commandIds.filter( - (commandId) => !removedCommandIds.has(commandId), - ); - gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter( - (artifactId) => !removedArtifactIds.has(artifactId), - ); -} -const referencedSchemaIds = new Set( - gatesDocument.artifacts.map(({ schemaId }) => schemaId), -); -gatesDocument.artifactSchemas = gatesDocument.artifactSchemas.filter( - ({ id }) => referencedSchemaIds.has(id), -); -const validatedGates = parseCiGateContract(gatesDocument); -await writeFile( - gatesPath, - `${JSON.stringify(validatedGates, null, 2)}\n`, -); -await generateCiWorkflow({ +await pruneRemovalFixtureCiContract({ root: fixtureRoot, - contract: validatedGates, - check: false, + removedScripts: new Set(runtimeScripts), + removedEvidencePathFragments, }); -await assertNoRuntimeImports(fixtureRoot); +await regenerateRemovalFixtureWorkflow(fixtureRoot); +await assertNoRuntimeImports(fixtureRoot, runtimeSourceRoots, "realtime"); const checks: Array = [ ["typecheck", runPnpm("check:types")], diff --git a/scripts/test-sample-removal.ts b/scripts/test-sample-removal.ts index 15e1462..682373c 100644 --- a/scripts/test-sample-removal.ts +++ b/scripts/test-sample-removal.ts @@ -1,25 +1,30 @@ -import { spawnSync } from "node:child_process"; import { access, - cp, mkdir, mkdtemp, readFile, - readdir, rm, - symlink, writeFile, } from "node:fs/promises"; import path from "node:path"; import { isProductionModulePath } from "./lib/risk-coverage.ts"; +import { + filesBelow, + prepareRemovalFixture, + pruneRemovalFixtureCiContract, + pruneScriptOrchestration, + regenerateRemovalFixtureWorkflow, + requireRemovalFixtureEnvironment, + runRemovalFixturePnpm, +} from "./lib/removal-fixture.ts"; const fixtureParent = path.resolve(".tmp"); await mkdir(fixtureParent, { recursive: true }); const fixtureRoot = await mkdtemp( path.join(fixtureParent, "reference-feature-removal-"), ); -const pnpmCli = requireEnvironment("npm_execpath"); +const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath"); const featureSource = "src/features/reference-feature"; const featureTests = "tests/features/reference-feature"; const commonTestPaths = [ @@ -162,36 +167,20 @@ type GovernanceRegistry = Record & { }; type RemovalGovernance = { registries: GovernanceRegistry[] }; -function requireEnvironment(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`${name} is required for sample removal`); - return value; -} - -async function filesBelow(directory: string): Promise { - const entries = await readdir(directory, { withFileTypes: true }); - const groups = await Promise.all( - entries.map((entry) => { - const target = path.join(directory, entry.name); - return entry.isDirectory() ? filesBelow(target) : [target]; - }), - ); - return groups.flat(); -} - function runPnpm(script: string, extra: string[] = []): boolean { - const result = spawnSync(process.execPath, [pnpmCli, script, ...extra], { - cwd: fixtureRoot, - stdio: "inherit", - }); - return result.status === 0; + return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script, extra); } try { - for (const target of copyTargets) { - await cp(target, path.join(fixtureRoot, target), { recursive: true }); + await prepareRemovalFixture(fixtureRoot, copyTargets); + for (const excludedFixtureTest of [ + "tests/unit/ci-workflow-generation.test.ts", + "tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap", + "tests/unit/removal-fixture.test.ts", + "tests/unit/http-scenario-evidence.test.ts", + ]) { + await rm(path.join(fixtureRoot, excludedFixtureTest), { force: true }); } - await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir"); const coveragePolicyFile = path.join( fixtureRoot, @@ -329,6 +318,33 @@ try { `${JSON.stringify(removalGovernance, null, 2)}\n`, ); + const removedCiScripts = new Set([ + "check:types:fixture:feature-input", + "check:types:fixture:reference-operation", + "test:http-scenario-evidence", + "test:reference-feature", + ]); + const fixturePackagePath = path.join(fixtureRoot, "package.json"); + const fixturePackage = JSON.parse(await readFile(fixturePackagePath, "utf8")) as { + scripts: Record; + }; + pruneScriptOrchestration(fixturePackage.scripts, "test:all", removedCiScripts); + fixturePackage.scripts["test:coverage"] = fixturePackage.scripts["test:coverage"] + .replace(" tests/features/reference-feature", ""); + delete fixturePackage.scripts["check:http-scenario-evidence"]; + delete fixturePackage.scripts["check:http-scenario-evidence:fixture"]; + await writeFile(fixturePackagePath, `${JSON.stringify(fixturePackage, null, 2)}\n`); + await pruneRemovalFixtureCiContract({ + root: fixtureRoot, + removedScripts: removedCiScripts, + removedEvidencePathFragments: [ + "reference-feature.xml", + "http-scenario-executions", + "http-scenario-evidence", + ], + }); + await regenerateRemovalFixtureWorkflow(fixtureRoot); + const residue: string[] = []; for (const root of ["src", "tests"]) { for (const file of await filesBelow(path.join(fixtureRoot, root))) { @@ -366,6 +382,7 @@ try { ["unit-integration", runPnpm("test:all")], ["coverage", runPnpm("test:coverage")], ["test-evidence-source", runPnpm("check:test-evidence:source")], + ["ci-contract", runPnpm("check:ci")], [ "home-smoke", runPnpm("exec", [ diff --git a/tests/fixtures/ci-contract/duplicate-gate-id.json b/tests/fixtures/ci-contract/duplicate-gate-id.json index 8cabfc9..99c58ee 100644 --- a/tests/fixtures/ci-contract/duplicate-gate-id.json +++ b/tests/fixtures/ci-contract/duplicate-gate-id.json @@ -3,7 +3,7 @@ "providerAdapter": ".gitea/workflows/quality-gates.yml", "commands": [], "artifactSchemas": [{ "id": "text", "kind": "text", "maxBytes": 1024 }], - "artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "text" }], + "artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "text", "production": "runner-generated" }], "gates": [ { "id": "FE-GATE-001", "name": "one", "commandIds": ["command"], "logArtifactId": "log", "evidenceArtifactIds": ["log"], "retentionClassId": "merge" }, { "id": "FE-GATE-001", "name": "duplicate", "commandIds": ["command"], "logArtifactId": "log", "evidenceArtifactIds": ["log"], "retentionClassId": "merge" } diff --git a/tests/fixtures/ci-contract/missing-artifact-schema.json b/tests/fixtures/ci-contract/missing-artifact-schema.json index a76e6b5..6f8e875 100644 --- a/tests/fixtures/ci-contract/missing-artifact-schema.json +++ b/tests/fixtures/ci-contract/missing-artifact-schema.json @@ -3,7 +3,7 @@ "providerAdapter": ".gitea/workflows/quality-gates.yml", "commands": [], "artifactSchemas": [], - "artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "missing" }], + "artifacts": [{ "id": "log", "path": "artifacts/gate.txt", "schemaId": "missing", "production": "runner-generated" }], "gates": [], "stages": [], "jobs": [], diff --git a/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap b/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap index 695700a..35e3db2 100644 --- a/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap +++ b/tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap @@ -66,7 +66,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Install Playwright browsers if: \${{ matrix.browser }} run: corepack pnpm exec playwright install --with-deps chromium firefox webkit @@ -106,7 +106,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Install Playwright browsers if: \${{ matrix.browser }} run: corepack pnpm exec playwright install --with-deps chromium firefox webkit @@ -139,7 +139,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Build candidate once and verify local evidence run: corepack pnpm ci:gate -- FE-GATE-015 - name: Archive and validate the exact candidate file set @@ -195,7 +195,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Download release candidate uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 with: @@ -237,7 +237,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Download release candidate uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 with: @@ -281,7 +281,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Download release candidate uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 with: @@ -339,7 +339,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Run blocking gate run: corepack pnpm ci:gate -- \${{ matrix.gate }} - name: Upload production gate evidence @@ -369,7 +369,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Run blocking gate run: corepack pnpm ci:gate -- FE-GATE-018 - name: Upload field gate evidence @@ -395,7 +395,7 @@ jobs: - name: Frozen install run: | corepack enable - corepack pnpm install --frozen-lockfile + corepack pnpm install --frozen-lockfile --ignore-scripts - name: Run documentation gate run: corepack pnpm ci:gate -- FE-GATE-017 - name: Upload documentation gate evidence diff --git a/tests/unit/ci-artifact-contract.test.ts b/tests/unit/ci-artifact-contract.test.ts index 4c86a7c..5767eda 100644 --- a/tests/unit/ci-artifact-contract.test.ts +++ b/tests/unit/ci-artifact-contract.test.ts @@ -55,7 +55,12 @@ async function writeArtifact(root: string, relative: string, value: string | Buf } function artifact(pathname: string, schemaId: string): CiGateArtifact { - return { id: `artifact-${schemaId}`, path: pathname, schemaId }; + return { + id: `artifact-${schemaId}`, + path: pathname, + schemaId, + production: "source-controlled", + }; } describe("CI artifact validator", () => { diff --git a/tests/unit/ci-workflow-generation.test.ts b/tests/unit/ci-workflow-generation.test.ts index 7247a42..5b65a2c 100644 --- a/tests/unit/ci-workflow-generation.test.ts +++ b/tests/unit/ci-workflow-generation.test.ts @@ -123,6 +123,7 @@ describe("CI gate contract", () => { ["duplicate stage id", (value: Record) => value.stages.push({ ...value.stages[0] }), /duplicate stage id/i], ["duplicate job id", (value: Record) => value.jobs.push({ ...value.jobs[0] }), /duplicate job id/i], ["duplicate artifact path", (value: Record) => value.artifacts.push({ ...value.artifacts[0], id: "duplicate-path" }), /duplicate artifact path/i], + ["missing artifact production classification", (value: Record) => delete value.artifacts[0].production, /production/i], ["unknown command reference", (value: Record) => value.gates[0].commandIds.push("missing-command"), /unknown command missing-command/i], ["unknown artifact reference", (value: Record) => (value.gates[0].logArtifactId = "missing-artifact"), /unknown artifact missing-artifact/i], ["unknown schema reference", (value: Record) => (value.artifacts[0].schemaId = "missing-schema"), /unknown artifact schema missing-schema/i], @@ -177,6 +178,27 @@ describe("CI gate contract", () => { await expect(loadCiGateContract(root)).rejects.toThrow(/missing package scripts/i); }); + it("rejects swapped canonical gate command ownership", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-contract-gate-shape-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + const contract = JSON.parse( + JSON.stringify(await loadCiGateContract(process.cwd())), + ) as Record; + const security = contract.gates.find((gate: Record) => gate.id === "FE-GATE-013"); + const documentation = contract.gates.find((gate: Record) => gate.id === "FE-GATE-017"); + [security.commandIds, documentation.commandIds] = [ + documentation.commandIds, + security.commandIds, + ]; + await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); + await writeFile(path.join(root, "package.json"), await readFile("package.json")); + + await expect(loadCiGateContract(root)).rejects.toThrow( + /canonical gate semantic shape|lacks a bound producer command/i, + ); + }); + it.each(["check:artifact-schemas", "check:ci-workflow"])( "rejects a missing nested check:ci dependency: %s", async (removedScript) => { @@ -191,11 +213,29 @@ describe("CI gate contract", () => { await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`); await expect(loadCiGateContract(root)).rejects.toThrow( - /missing package scripts|package script graph invalid/i, + /missing package scripts|package script graph invalid|canonical check:ci dependency/i, ); }, ); + it.each(["check:artifact-schemas", "check:ci-workflow"])( + "rejects a no-op nested check:ci dependency: %s", + async (bypassedScript) => { + const root = await mkdtemp(path.join(tmpdir(), "ci-contract-script-meaning-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + const contract = await loadCiGateContract(process.cwd()); + const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { + scripts: Record; + }; + packageDocument.scripts[bypassedScript] = "true"; + await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); + await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`); + + await expect(loadCiGateContract(root)).rejects.toThrow(/canonical check:ci dependency/i); + }, + ); + it.each([ ["true bypass", "true"], ["direct self recursion", "corepack pnpm check:ci"], @@ -292,6 +332,79 @@ describe("CI gate contract", () => { expect(log.byteLength).toBeLessThanOrEqual(8_192); expect(log.toString("utf8")).toMatch(/aggregate output|INFRASTRUCTURE_FAILURE/i); }, 20_000); + + it("rejects stale command-generated evidence from a successful no-op producer", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-gate-stale-evidence-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + await mkdir(path.join(root, "artifacts/tests"), { recursive: true }); + const contract = JSON.parse( + JSON.stringify(await loadCiGateContract(process.cwd())), + ) as Record; + const command = contract.commands.find( + (entry: Record) => entry.id === "test-runtime-schema", + ); + command.script = "test:stale-evidence-noop"; + const evidence = contract.artifacts.find( + (entry: Record) => entry.path === "artifacts/tests/runtime-schema.xml", + ); + const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { + scripts: Record; + }; + packageDocument.scripts[command.script] = "true"; + await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); + await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`); + await writeFile( + path.join(root, evidence.path), + '\n', + ); + + const result = spawnSync( + process.execPath, + [path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"], + { cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } }, + ); + + expect(result.status).toBe(1); + expect( + await readFile(path.join(root, "artifacts/quality/gates/FE-GATE-004.txt"), "utf8"), + ).toMatch(/not freshly produced/i); + }); + + it("accepts a fresh deterministic rewrite with identical evidence bytes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-gate-identical-rewrite-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + await mkdir(path.join(root, "artifacts/tests"), { recursive: true }); + const contract = JSON.parse( + JSON.stringify(await loadCiGateContract(process.cwd())), + ) as Record; + const command = contract.commands.find( + (entry: Record) => entry.id === "test-runtime-schema", + ); + command.script = "test:identical-evidence-rewrite"; + const evidence = contract.artifacts.find( + (entry: Record) => entry.path === "artifacts/tests/runtime-schema.xml", + ); + const evidenceBytes = '\n'; + const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { + scripts: Record; + }; + packageDocument.scripts[command.script] = + `node -e 'require("node:fs").writeFileSync("${evidence.path}", Buffer.from("${Buffer.from(evidenceBytes).toString("base64")}", "base64"))'`; + await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); + await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`); + await writeFile(path.join(root, evidence.path), evidenceBytes); + + const result = spawnSync( + process.execPath, + [path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"], + { cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toMatch(/FE-GATE-004 runtime-schema: PASS/); + }); }); describe("CI workflow generation", () => { @@ -311,6 +424,10 @@ describe("CI workflow generation", () => { ); expect(first).not.toContain("process_dist_sha256"); expect(first).toContain("persist-credentials: false"); + expect( + first.match(/corepack pnpm install --frozen-lockfile --ignore-scripts/gu), + ).toHaveLength(9); + expect(first).not.toMatch(/corepack pnpm install --frozen-lockfile$/mu); expect(first).toContain("verify-ci-candidate-archive.ts --archive"); expect(first).toContain("--extract-to"); expect(first).not.toMatch(/\btar\s+[^\n]*--extract/u); diff --git a/tests/unit/removal-fixture.test.ts b/tests/unit/removal-fixture.test.ts new file mode 100644 index 0000000..b88155b --- /dev/null +++ b/tests/unit/removal-fixture.test.ts @@ -0,0 +1,54 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, expect, it } from "vitest"; + +import { loadCiGateContract } from "../../scripts/contracts/ci-gates.ts"; +import { + pruneRemovalFixtureCiContract, +} from "../../scripts/lib/removal-fixture.ts"; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +it("prunes removed command and evidence references from a reduced CI contract", async () => { + const root = await mkdtemp(path.join(tmpdir(), "removal-fixture-contract-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + await writeFile(path.join(root, "config/ci/gates.json"), await readFile("config/ci/gates.json")); + await writeFile(path.join(root, "package.json"), await readFile("package.json")); + + await pruneRemovalFixtureCiContract({ + root, + removedScripts: new Set(["test:reference-feature"]), + removedEvidencePathFragments: ["reference-feature.xml"], + }); + + const contract = await loadCiGateContract(root, { mode: "removal-fixture" }); + expect(contract.commands.some(({ script }) => script === "test:reference-feature")).toBe(false); + expect(contract.artifacts.some(({ path }) => path.includes("reference-feature.xml"))).toBe(false); + expect(contract.gates.some(({ commandIds }) => commandIds.includes("test-reference-feature"))).toBe(false); + expect(contract.gates.some(({ evidenceArtifactIds }) => + evidenceArtifactIds.includes("artifact-artifacts-tests-reference-feature-xml") + )).toBe(false); +}); + +it("rejects pruning that leaves a reduced gate without commands", async () => { + const root = await mkdtemp(path.join(tmpdir(), "removal-fixture-empty-gate-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + await writeFile(path.join(root, "config/ci/gates.json"), await readFile("config/ci/gates.json")); + await writeFile(path.join(root, "package.json"), await readFile("package.json")); + + await expect(pruneRemovalFixtureCiContract({ + root, + removedScripts: new Set(["test:runtime-schema"]), + removedEvidencePathFragments: ["runtime-schema.xml"], + })).rejects.toThrow(/commandIds|too small|at least 1/i); +}); diff --git a/tests/unit/supply-chain.test.ts b/tests/unit/supply-chain.test.ts index 2d52e36..281bdf3 100644 --- a/tests/unit/supply-chain.test.ts +++ b/tests/unit/supply-chain.test.ts @@ -704,7 +704,11 @@ describe("supply-chain policy", () => { }); it("wires the exact security fixture checker as a passing CI gate", async () => { - const contract = await loadCiGateContract(process.cwd()); + const contract = await loadCiGateContract(process.cwd(), { + mode: process.env.CI_CONTRACT_MODE === "removal-fixture" + ? "removal-fixture" + : "canonical", + }); const index = indexCiGateContract(contract); const securityGate = index.gates.get("FE-GATE-013"); expect(securityGate).toBeDefined();