From 655135d45da790d8ce00fa86d133486ed947c39c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 06:53:06 +0000 Subject: [PATCH 1/8] UN-4363 [FEAT] Enforce the frontend v1 rules as separate PR checks (P0-T10) - ci-frontend-v1.yaml: v1 checks as their own jobs (Biome, Typecheck, Purity, Unit tests, Build/bundle/separation, Guard fixtures), moved out of the v0 typecheck job so v1 reports apart from v0. - ci-frontend-v1-seam.yaml + scripts/check-v1-seam.mjs: v1 work may change v0 (src/ outside v1, public/) only at scripts/v1-seam-allowlist.json. Change sets that touch no v1 file pass; the v0-change label turns it into a report. - scripts/check-v1-separation.mjs: no tracked plugin folder under src/v1/plugins (enterprise code stays in unstract-cloud). - scripts/check-v1-bundle.mjs: production build, then no v1 chunk or chunk it loads holds a v0 module or antd. - biome.json: a11y rules are errors under src/v1; lint:v1 fails on warnings. - 17 fixture cases proving each new guard fails and passes as specified, including two real Vite builds for the bundle guard. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HzRGGUgsEDiwJ56zKJ4ASv --- .github/workflows/ci-frontend-lint.yaml | 27 +-- .github/workflows/ci-frontend-v1-seam.yaml | 43 ++++ .github/workflows/ci-frontend-v1.yaml | 224 ++++++++++++++++++ frontend/biome.json | 13 + frontend/package.json | 7 +- frontend/scripts/check-v1-bundle.mjs | 195 +++++++++++++++ frontend/scripts/check-v1-seam.mjs | 186 +++++++++++++++ frontend/scripts/check-v1-separation.mjs | 86 +++++++ frontend/scripts/v1-seam-allowlist.json | 10 + frontend/src/v1/README.md | 11 +- .../v1-boundary/boundary.fixtures.mjs | 25 +- .../cases/bundle-v1-antd-report/report.json | 22 ++ .../cases/bundle-v1-clean/index.html | 6 + .../cases/bundle-v1-clean/src/legacy.js | 1 + .../cases/bundle-v1-clean/src/main.js | 4 + .../src/v1/pages/demo/index.ts | 1 + .../cases/bundle-v1-clean/vite.config.js | 6 + .../cases/bundle-v1-pulls-v0/index.html | 6 + .../cases/bundle-v1-pulls-v0/src/legacy.js | 1 + .../cases/bundle-v1-pulls-v0/src/main.js | 1 + .../src/v1/pages/demo/index.ts | 3 + .../cases/bundle-v1-pulls-v0/vite.config.js | 6 + .../cases/seam-v0-fix-only/changed.txt | 2 + .../cases/seam-v1-at-the-seam/changed.txt | 4 + .../cases/seam-v1-changes-public/changed.txt | 2 + .../cases/seam-v1-changes-v0-page/changed.txt | 2 + .../cases/seam-v1-only/changed.txt | 3 + .../seam-v1-tooling-changes-v0/changed.txt | 2 + .../src/v1/components/ui/Bad.tsx | 3 + .../src/v1/components/ui/Button.ts | 3 + .../src/v1/plugins/acme/index.ts | 1 + .../src/v1/components/ui/List.tsx | 9 + 32 files changed, 886 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/ci-frontend-v1-seam.yaml create mode 100644 .github/workflows/ci-frontend-v1.yaml create mode 100644 frontend/scripts/check-v1-bundle.mjs create mode 100644 frontend/scripts/check-v1-seam.mjs create mode 100644 frontend/scripts/check-v1-separation.mjs create mode 100644 frontend/scripts/v1-seam-allowlist.json create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-antd-report/report.json create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/legacy.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/main.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/v1/pages/demo/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/vite.config.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/legacy.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/main.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/v1/pages/demo/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/vite.config.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-v0-fix-only/changed.txt create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-v1-at-the-seam/changed.txt create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-public/changed.txt create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-v0-page/changed.txt create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-v1-only/changed.txt create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-v1-tooling-changes-v0/changed.txt create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-a11y-button-type/src/v1/components/ui/Bad.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-shim/src/v1/components/ui/Button.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-plugin-folder/src/v1/plugins/acme/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-warning-is-error/src/v1/components/ui/List.tsx diff --git a/.github/workflows/ci-frontend-lint.yaml b/.github/workflows/ci-frontend-lint.yaml index 9cb464f306..46e4e24abb 100644 --- a/.github/workflows/ci-frontend-lint.yaml +++ b/.github/workflows/ci-frontend-lint.yaml @@ -133,35 +133,12 @@ jobs: # esbuild ship per-platform binaries as optionalDependencies). run: bun install --frozen-lockfile --ignore-scripts + # src/v1 is excluded here: its checks run, and report, separately in + # ci-frontend-v1.yaml and ci-frontend-v1-seam.yaml. - name: Run TypeScript typecheck working-directory: frontend run: bun run typecheck - # src/v1 is excluded from the step above and checked under its own - # stricter tsconfig, after scripts/check-v1.mjs checks file types, - # placement, symlinks and the folder taxonomy. - - name: Run v1 typecheck - working-directory: frontend - run: bun run typecheck:v1 - - # Biome on src/v1, then scripts/check-v1.mjs: the folder import - # contract, v0 reaching v1 only through `@v1`, PropTypes and `ant-`. - - name: Run v1 lint - working-directory: frontend - run: bun run lint:v1 - - # The guardrail fixtures run under `node --test` (Node 22 APIs). - - name: Setup Node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: "22" - - # v1 unit tests, then the fixtures proving each guard above fails and - # passes when it should. - - name: Run v1 tests - working-directory: frontend - run: bun run test:v1 - # ci-test.yaml runs the same suite as the rig's `frontend` group, but its # tiers skip frontend-only PRs; this job is what gates those. vitest: diff --git a/.github/workflows/ci-frontend-v1-seam.yaml b/.github/workflows/ci-frontend-v1-seam.yaml new file mode 100644 index 0000000000..e0982d35f9 --- /dev/null +++ b/.github/workflows/ci-frontend-v1-seam.yaml @@ -0,0 +1,43 @@ +name: Frontend v1 seam + +# Seam guard (P0-T10, UN-4363): until Phase 9, a PR that is frontend v1 work +# may change v0 only at the files in frontend/scripts/v1-seam-allowlist.json. +# A PR that touches no v1 file is v0 work and passes untouched. +# +# The `v0-change` label lets v1 work that genuinely needs a v0 change through; +# the guard then lists the files instead of failing. Its own workflow, because +# it re-runs on label changes and nothing else needs to. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + branches: [main] + +permissions: + contents: read + +jobs: + seam: + name: v1 · Seam + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + # The merge base with the PR's base branch must be reachable. + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + + # Node built-ins only, so no install step. + - name: Run the seam guard + working-directory: frontend + env: + BASE_REF: ${{ github.base_ref }} + OVERRIDE: ${{ contains(github.event.pull_request.labels.*.name, 'v0-change') && '--override' || '' }} + run: node scripts/check-v1-seam.mjs --base "origin/$BASE_REF" $OVERRIDE diff --git a/.github/workflows/ci-frontend-v1.yaml b/.github/workflows/ci-frontend-v1.yaml new file mode 100644 index 0000000000..e97d50c1b2 --- /dev/null +++ b/.github/workflows/ci-frontend-v1.yaml @@ -0,0 +1,224 @@ +name: Frontend v1 checks + +# The frontend v1 rules as PR checks (P0-T10, UN-4363), reported apart from +# v0's checks in ci-frontend-lint.yaml. Each guard is its own job so a PR shows +# which rule failed by name. Every guard has fixture cases in +# frontend/test-fixtures/v1-boundary/ proving it fails when it should; the +# "Guard fixtures" job runs them. +# +# The seam guard reads PR labels, so it lives in ci-frontend-v1-seam.yaml: +# re-running these jobs on every label change would cost minutes for nothing. +# +# This workflow runs on a clean checkout of the public repository, with no +# cloud overlay, so a green Build job is the separation guard's "v1 builds +# with no overlay" half. + +on: + push: + branches: + - main + paths: + - "frontend/**" + # No `paths` on pull_request: an untriggered workflow reports no check run, + # so a required check would wait forever on non-frontend PRs. Filtering is + # done by the `changes` job instead; a job skipped via `if:` reports + # `skipped`, which satisfies a required check. Same as ci-frontend-lint.yaml. + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: [main] + +permissions: + contents: read + +jobs: + # Gating jobs run when `changes` finds frontend files, AND whenever `changes` + # itself did not succeed: a broken filter must fail open (run the checks), + # never skip them. `!cancelled()` lets them run past a failed dependency. + changes: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + permissions: + contents: read + # dorny/paths-filter lists PR files via the API. + pull-requests: read + outputs: + # Push is already path-filtered by its trigger, so force true off-PR. + frontend: ${{ github.event_name != 'pull_request' || steps.filter.outputs.frontend == 'true' }} + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Check for frontend changes + if: github.event_name == 'pull_request' + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + id: filter + with: + filters: | + frontend: + - "frontend/**" + + # Biome's recommended rules and the accessibility rules as errors: biome.json + # raises the a11y rules for src/v1, and --error-on-warnings turns every + # remaining warning under src/v1 into a failure. + biome: + name: v1 · Biome + needs: changes + if: ${{ !cancelled() && github.event.pull_request.draft != true && (needs.changes.result != 'success' || needs.changes.outputs.frontend == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Biome + uses: biomejs/setup-biome@4c91541eaada48f67d7dbd7833600ce162b68f51 # v2.7.1 + with: + version: "2.3.13" + + - name: Run Biome on src/v1 + working-directory: frontend + run: biome ci --error-on-warnings src/v1/ + + typecheck: + name: v1 · Typecheck + needs: changes + if: ${{ !cancelled() && github.event.pull_request.draft != true && (needs.changes.result != 'success' || needs.changes.outputs.frontend == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Install dependencies + working-directory: frontend + # No dependency lifecycle scripts: nothing here needs them. + run: bun install --frozen-lockfile --ignore-scripts + + # scripts/check-v1.mjs --files (TypeScript only, styles only in styles/, + # allowed file types, no symlinks, folder taxonomy), then strict tsc. + - name: Run v1 typecheck + working-directory: frontend + run: bun run typecheck:v1 + + # No shim, antd, `ant-` class, CSS file, inline style, PropTypes or JS file + # under v1, plus the folder import contract and the styling policy. The JS + # and CSS-file rules run in the Typecheck job (check-v1 --files). + purity: + name: v1 · Purity + needs: changes + if: ${{ !cancelled() && github.event.pull_request.draft != true && (needs.changes.result != 'success' || needs.changes.outputs.frontend == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Install dependencies + working-directory: frontend + run: bun install --frozen-lockfile --ignore-scripts + + - name: Run the import and purity checks + working-directory: frontend + run: node scripts/check-v1.mjs --code + + - name: Run the styling policy + working-directory: frontend + run: node scripts/check-v1-styles.mjs + + # The v1 unit tests. P0-T08 (UN-4329) replaces this with the frontend-v1 + # unit group. + unit: + name: v1 · Unit tests + needs: changes + if: ${{ !cancelled() && github.event.pull_request.draft != true && (needs.changes.result != 'success' || needs.changes.outputs.frontend == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Install dependencies + working-directory: frontend + run: bun install --frozen-lockfile --ignore-scripts + + - name: Run v1 unit tests + working-directory: frontend + # The lockfile's vitest, never a fetched one. + run: ./node_modules/.bin/vitest run --dir src/v1 --passWithNoTests + + # One production build, then the bundle guard (no v1 chunk loads v0 or + # antd) and the separation guard (no enterprise plugin code in this repo). + build: + name: v1 · Build, bundle and separation + needs: changes + if: ${{ !cancelled() && github.event.pull_request.draft != true && (needs.changes.result != 'success' || needs.changes.outputs.frontend == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + + - name: Install dependencies + working-directory: frontend + run: bun install --frozen-lockfile --ignore-scripts + + - name: Build and run the bundle guard + working-directory: frontend + run: node scripts/check-v1-bundle.mjs + + - name: Run the separation guard + working-directory: frontend + run: node scripts/check-v1-separation.mjs + + # Proves every guard above fails and passes when it should (node --test, + # Node 22 APIs). Spawns the real tools, two small Vite builds included. + guard-fixtures: + name: v1 · Guard fixtures + needs: changes + if: ${{ !cancelled() && github.event.pull_request.draft != true && (needs.changes.result != 'success' || needs.changes.outputs.frontend == 'true') }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + + - name: Install dependencies + working-directory: frontend + run: bun install --frozen-lockfile --ignore-scripts + + - name: Run the guard fixtures + working-directory: frontend + run: node --test test-fixtures/v1-boundary/boundary.fixtures.mjs diff --git a/frontend/biome.json b/frontend/biome.json index 253b99397c..1c8e4085c4 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -182,6 +182,19 @@ } } }, + "a11y": { + "noLabelWithoutControl": "error", + "noNoninteractiveElementInteractions": "error", + "noRedundantRoles": "error", + "noStaticElementInteractions": "error", + "useAriaPropsSupportedByRole": "error", + "useButtonType": "error", + "useFocusableInteractive": "error", + "useGenericFontNames": "error", + "useKeyWithClickEvents": "error", + "useSemanticElements": "error", + "useValidAnchor": "error" + }, "suspicious": { "noExplicitAny": "error" } diff --git a/frontend/package.json b/frontend/package.json index f9e88ea682..7ce925a81e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -80,8 +80,11 @@ "typecheck": "tsc --noEmit", "tokens:v1": "bun scripts/build-v1-tokens.mjs", "typecheck:v1": "node scripts/check-v1.mjs --files && tsc -p src/v1/tsconfig.json", - "lint:v1": "biome lint src/v1/ && node scripts/check-v1.mjs --code && node scripts/check-v1-styles.mjs", - "test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs" + "lint:v1": "biome lint --error-on-warnings src/v1/ && node scripts/check-v1.mjs --code && node scripts/check-v1-styles.mjs", + "test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs", + "seam:v1": "node scripts/check-v1-seam.mjs --base origin/main", + "separation:v1": "node scripts/check-v1-separation.mjs", + "bundle:v1": "node scripts/check-v1-bundle.mjs" }, "browserslist": { "production": [ diff --git a/frontend/scripts/check-v1-bundle.mjs b/frontend/scripts/check-v1-bundle.mjs new file mode 100644 index 0000000000..9230187cee --- /dev/null +++ b/frontend/scripts/check-v1-bundle.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +/* + * Bundle guard for the frontend v1 epic (P0-T10): a v1 chunk never pulls in + * a v0 module or antd. + * + * Runs the production build (vite.config.js, unchanged) with one extra Rollup + * plugin that records every output chunk: its entry module, the modules + * bundled into it and the chunks it imports. Then, for every v1 chunk, it + * walks the chunks that chunk loads (static and dynamic imports) and fails on + * any module from src/ outside src/v1/ (v0, including the antd shims) or from + * the antd packages. + * + * A v1 chunk is one whose entry module is under src/v1/, or whose own source + * modules are all under src/v1/. A chunk that only shares vendor code + * (node_modules) is neither v0 nor v1. Until v1 is loaded through its own + * import (the version switch, P0-T03) the build has no v1 chunk and the guard + * passes with nothing to check; the fixtures prove it fails on a bad tree. + * + * (no flags) Build, then check. The build is also the PR's + * "v1 builds with no overlay" check. + * --report Check a saved report instead of building. + * --save-report Build, write the report there, then check. + */ +import fs from "node:fs"; +import path from "node:path"; + +const FRONTEND = path.join(import.meta.dirname, ".."); +const ANTD = /\/node_modules\/(antd|@ant-design\/[^/]+)\//; + +function usage(message) { + console.error(`check-v1-bundle: ${message}`); + console.error( + "usage: check-v1-bundle.mjs [--report | --save-report ]", + ); + process.exit(2); +} + +function parseArgs(argv) { + const opts = { report: null, saveReport: null }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const key = { "--report": "report", "--save-report": "saveReport" }[arg]; + if (!key) { + usage(`unknown argument '${arg}'`); + } + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + usage(`${arg} needs a value`); + } + opts[key] = value; + i += 1; + } + if (opts.report && opts.saveReport) { + usage("pass at most one of --report and --save-report"); + } + return opts; +} + +/* A module id as the report keeps it: frontend-relative for our sources, + * as-is (absolute) for packages, null for virtual modules. */ +function moduleName(id) { + if (id.startsWith("\0")) { + return null; + } + const file = id.split("?")[0]; + if (file.includes("/node_modules/")) { + return file; + } + const rel = path.relative(FRONTEND, file).split(path.sep).join("/"); + return rel.startsWith("..") ? file : rel; +} + +function reporter(chunks) { + return { + name: "v1-bundle-report", + apply: "build", + generateBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== "chunk") { + continue; + } + chunks.push({ + fileName: output.fileName, + facade: output.facadeModuleId + ? moduleName(output.facadeModuleId) + : null, + modules: Object.keys(output.modules) + .map(moduleName) + .filter(Boolean) + .sort(), + imports: [...output.imports, ...output.dynamicImports].sort(), + }); + } + }, + }; +} + +async function buildReport() { + const { build } = await import("vite"); + const chunks = []; + await build({ + root: FRONTEND, + configFile: path.join(FRONTEND, "vite.config.js"), + logLevel: "warn", + plugins: [reporter(chunks)], + }); + return { chunks }; +} + +const kind = (name) => { + if (ANTD.test(name)) { + return "antd"; + } + if (name.startsWith("src/v1/")) { + return "v1"; + } + if (name.startsWith("src/")) { + return "v0"; + } + return "other"; +}; + +function isV1Chunk(chunk) { + if (chunk.facade) { + return kind(chunk.facade) === "v1"; + } + const own = chunk.modules.map(kind).filter((k) => k === "v1" || k === "v0"); + return own.length > 0 && own.every((k) => k === "v1"); +} + +function check(report) { + const byName = new Map(report.chunks.map((chunk) => [chunk.fileName, chunk])); + const problems = []; + const v1Chunks = report.chunks.filter(isV1Chunk); + for (const root of v1Chunks) { + const seen = new Set(); + const queue = [{ chunk: root, via: [] }]; + while (queue.length > 0) { + const { chunk, via } = queue.shift(); + if (seen.has(chunk.fileName)) { + continue; + } + seen.add(chunk.fileName); + for (const name of chunk.modules) { + const k = kind(name); + if (k === "v0" || k === "antd") { + const where = via.length + ? ` through ${[...via, chunk.fileName].join(" -> ")}` + : ""; + const what = k === "v0" ? "v0 module" : "antd module"; + problems.push( + `${root.fileName} (v1${root.facade ? `, from ${root.facade}` : ""}) pulls in ${what} ${name}${where}.`, + ); + } + } + for (const next of chunk.imports) { + const target = byName.get(next); + if (target) { + queue.push({ chunk: target, via: [...via, chunk.fileName] }); + } + } + } + } + return { v1Chunks: v1Chunks.length, problems }; +} + +const opts = parseArgs(process.argv.slice(2)); +let report; +if (opts.report) { + try { + report = JSON.parse(fs.readFileSync(opts.report, "utf8")); + } catch (error) { + console.error( + `check-v1-bundle: cannot read ${opts.report}: ${error.message}`, + ); + process.exit(2); + } +} else { + report = await buildReport(); + if (opts.saveReport) { + fs.writeFileSync(opts.saveReport, `${JSON.stringify(report, null, 2)}\n`); + } +} + +const { v1Chunks, problems } = check(report); +if (problems.length > 0) { + console.error(problems.join("\n")); + console.error( + `\ncheck-v1-bundle: ${problems.length} problem(s). v1 chunks may load only v1 code and packages other than antd.`, + ); + process.exit(1); +} +console.log( + `check-v1-bundle: ok (${report.chunks.length} chunk(s), ${v1Chunks} v1 chunk(s))`, +); diff --git a/frontend/scripts/check-v1-seam.mjs b/frontend/scripts/check-v1-seam.mjs new file mode 100644 index 0000000000..8c1d832737 --- /dev/null +++ b/frontend/scripts/check-v1-seam.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +/* + * Seam guard for the frontend v1 epic (P0-T10). Until Phase 9, v1 work may + * change v0 only at the seam: the files in scripts/v1-seam-allowlist.json. + * + * A change set is v1 work when it touches src/v1/ or the v1 tooling + * (scripts/*v1*, test-fixtures/v1-*). Anything else is v0 work, such as a v0 + * bug fix, and passes untouched: the seam limits the epic, not v0. + * + * "v0" here is the code that ships as v0: src/ outside src/v1/, and public/. + * Build, test and CI configuration (package.json, vite.config.js, biome.json, + * scripts/, test-fixtures/, docs/, ...) sits outside both and is never + * guarded. A deleted or renamed v0 file counts as a change to it. + * + * --base Changed files are `git diff ...HEAD` (CI passes the + * PR's base branch). + * --changed Changed files are read from , one path per line, + * relative to frontend/ (used by the fixtures). + * --override Report the files, then pass. CI sets it when the PR + * carries the `v0-change` label: v1 work that genuinely + * needs a v0 change, made visible to reviewers by the + * label. + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const FRONTEND = path.join(import.meta.dirname, ".."); +const ALLOWLIST = path.join(import.meta.dirname, "v1-seam-allowlist.json"); +const GUARDED = [/^src\/(?!v1\/)/, /^public\//]; +const V1_WORK = [ + /^src\/v1\//, + /^scripts\/[^/]*v1[^/]*$/, + /^test-fixtures\/v1-[^/]+\//, +]; + +function usage(message) { + console.error(`check-v1-seam: ${message}`); + console.error( + "usage: check-v1-seam.mjs (--base | --changed ) [--override]", + ); + process.exit(2); +} + +function parseArgs(argv) { + const opts = { base: null, changed: null, override: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--override") { + opts.override = true; + } else if (arg === "--base" || arg === "--changed") { + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + usage(`${arg} needs a value`); + } + opts[arg.slice(2)] = value; + i += 1; + } else { + usage(`unknown argument '${arg}'`); + } + } + if (Boolean(opts.base) === Boolean(opts.changed)) { + usage("pass exactly one of --base and --changed"); + } + return opts; +} + +/* `**` crosses folders, `*` and `?` stay inside one; nothing else is special. */ +function globToRegExp(glob) { + let source = ""; + for (let i = 0; i < glob.length; i += 1) { + const ch = glob[i]; + if (ch === "*" && glob[i + 1] === "*") { + // `**/` also matches no folder at all. + if (glob[i + 2] === "/") { + source += "(?:.*/)?"; + i += 2; + } else { + source += ".*"; + i += 1; + } + } else if (ch === "*") { + source += "[^/]*"; + } else if (ch === "?") { + source += "[^/]"; + } else { + source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + } + } + return new RegExp(`^${source}$`); +} + +function readAllowlist() { + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(ALLOWLIST, "utf8")); + } catch (error) { + console.error(`check-v1-seam: cannot read ${ALLOWLIST}: ${error.message}`); + process.exit(2); + } + if ( + !Array.isArray(parsed.allow) || + parsed.allow.some((glob) => typeof glob !== "string" || glob === "") + ) { + console.error(`check-v1-seam: ${ALLOWLIST} needs an "allow" list of globs`); + process.exit(2); + } + return parsed.allow.map((glob) => ({ glob, re: globToRegExp(glob) })); +} + +function changedFromGit(base) { + const result = spawnSync( + "git", + [ + "diff", + "--name-only", + "--no-renames", + "--relative", + `${base}...HEAD`, + "--", + ".", + ], + { cwd: FRONTEND, encoding: "utf8" }, + ); + if (result.error || result.status !== 0) { + console.error( + `check-v1-seam: git diff against '${base}' failed: ${result.error?.message ?? result.stderr.trim()}`, + ); + process.exit(2); + } + return result.stdout.split("\n"); +} + +function changedFromFile(file) { + try { + return fs.readFileSync(file, "utf8").split("\n"); + } catch (error) { + console.error(`check-v1-seam: cannot read ${file}: ${error.message}`); + process.exit(2); + } +} + +const opts = parseArgs(process.argv.slice(2)); +const allow = readAllowlist(); +const changed = ( + opts.base ? changedFromGit(opts.base) : changedFromFile(opts.changed) +) + .map((line) => line.trim().replace(/^\.\//, "")) + .filter(Boolean); + +if (!changed.some((file) => V1_WORK.some((re) => re.test(file)))) { + console.log( + `check-v1-seam: ok (${changed.length} changed file(s), none of them v1 work)`, + ); + process.exit(0); +} + +const outside = changed.filter( + (file) => + GUARDED.some((re) => re.test(file)) && + !allow.some(({ re }) => re.test(file)), +); + +if (outside.length === 0) { + console.log( + `check-v1-seam: ok (${changed.length} changed file(s), none in v0 outside the seam)`, + ); + process.exit(0); +} + +const lines = outside.map( + (file) => + `frontend/${file}: changes v0 outside the seam allowlist (scripts/v1-seam-allowlist.json).`, +); +if (opts.override) { + console.log(lines.join("\n")); + console.log( + `\ncheck-v1-seam: ${outside.length} v0 file(s) changed; passing because the PR is labelled v0-change.`, + ); + process.exit(0); +} +console.error(lines.join("\n")); +console.error( + `\ncheck-v1-seam: ${outside.length} v0 file(s) outside the seam. v1 work changes v0 only at the seam; if this v1 work genuinely needs the v0 change, label the PR v0-change.`, +); +process.exit(1); diff --git a/frontend/scripts/check-v1-separation.mjs b/frontend/scripts/check-v1-separation.mjs new file mode 100644 index 0000000000..ec31150194 --- /dev/null +++ b/frontend/scripts/check-v1-separation.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +/* + * Separation guard for the frontend v1 epic (P0-T10): the public repository's + * v1 tree holds no enterprise plugin code. + * + * Cloud v1 plugins live in unstract-cloud and are copied into + * src/v1/plugins// at build time (spec decision D7). The public tree keeps + * only the plugin registry's own top-level files there, so any folder under + * src/v1/plugins/ is an overlaid plugin. + * + * Inside a git work tree only tracked files count, so a developer with the + * cloud overlay copied in (ignored by .gitignore) still passes; what fails is + * a plugin that was committed. Outside git (the fixtures) the folder itself + * is read. + * + * The other half of the guard, that v1 builds, lints and passes its tests + * with no overlay present, is the v1 CI jobs themselves: they run on a clean + * checkout of this repository, which has no overlay. + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const FRONTEND = path.join(import.meta.dirname, ".."); +const PLUGINS_REL = "src/v1/plugins"; +const PLUGINS = path.join(FRONTEND, PLUGINS_REL); + +function trackedPluginPaths() { + // Paths come back relative to the working directory, i.e. frontend/. + const result = spawnSync("git", ["ls-files", "--", PLUGINS_REL], { + cwd: FRONTEND, + encoding: "utf8", + }); + if (result.error || result.status !== 0) { + return null; + } + return result.stdout + .split("\n") + .filter(Boolean) + .map((file) => file.slice(PLUGINS_REL.length + 1)); +} + +function diskPluginPaths() { + if (!fs.existsSync(PLUGINS)) { + return []; + } + return fs + .readdirSync(PLUGINS, { recursive: true, withFileTypes: true }) + .filter((entry) => !entry.isDirectory()) + .map((entry) => + path + .relative(PLUGINS, path.join(entry.parentPath, entry.name)) + .split(path.sep) + .join("/"), + ); +} + +const insideGit = + spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: FRONTEND, + encoding: "utf8", + }).stdout.trim() === "true"; +const files = (insideGit && trackedPluginPaths()) || diskPluginPaths(); + +const plugins = [ + ...new Set( + files + .filter((file) => file.includes("/")) + .map((file) => file.split("/")[0]), + ), +].sort(); + +if (plugins.length === 0) { + console.log("check-v1-separation: ok"); + process.exit(0); +} +console.error( + plugins + .map( + (id) => + `${PLUGINS_REL}/${id}/: enterprise plugin code in the public repository. Cloud v1 plugins live in unstract-cloud and are copied in at build time (spec D7).`, + ) + .join("\n"), +); +console.error(`\ncheck-v1-separation: ${plugins.length} plugin folder(s).`); +process.exit(1); diff --git a/frontend/scripts/v1-seam-allowlist.json b/frontend/scripts/v1-seam-allowlist.json new file mode 100644 index 0000000000..9ed7c51488 --- /dev/null +++ b/frontend/scripts/v1-seam-allowlist.json @@ -0,0 +1,10 @@ +{ + "$comment": "v0 files the frontend v1 epic may change until Phase 9 (the seam allowlist in the Frontend v1 spec): route definitions, the root provider mount and the global stylesheet that loads Tailwind and the v1 styles. Build, test and CI configuration outside src/ and public/ is never guarded. The v0 Human Review header is cloud-only, so it is allowed by the cloud copy of this list. Read by scripts/check-v1-seam.mjs; globs are relative to frontend/, `*` stays inside one folder and `**` crosses folders.", + "allow": [ + "src/routes/**", + "src/index.jsx", + "src/App.jsx", + "src/index.css", + "src/setupTests.js" + ] +} diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 0bf54e99f7..331b275c9d 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -91,9 +91,18 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | Script | Checks | | --- | --- | | `bun run typecheck:v1` | TypeScript-only files, styles only in `styles/`, allowed file types only, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | -| `bun run lint:v1` | Biome on `src/v1/`, then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, then the styling policy (S1 to S8 above) | +| `bun run lint:v1` | Biome on `src/v1/` (warnings fail), then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, then the styling policy (S1 to S8 above) | | `bun run tokens:v1` | Regenerates `styles/tokens.css` from `styles/tokens.json` (via `styles/tokens.ts`; runs under bun, which imports the TypeScript source directly) | | `bun run test:v1` | v1 unit tests, then the guardrail fixtures in `test-fixtures/v1-boundary/` | +| `bun run seam:v1` | Seam guard: v1 work changes v0 (`src/` outside v1, `public/`) only at the files in `scripts/v1-seam-allowlist.json`, compared with `origin/main` | +| `bun run separation:v1` | No enterprise plugin code: no folder under `plugins/` is tracked in the public repository | +| `bun run bundle:v1` | Production build, then no v1 chunk (or a chunk it loads) holds a v0 module or antd | + +In CI these run as separate `v1 · …` checks (`.github/workflows/ci-frontend-v1.yaml` +and `ci-frontend-v1-seam.yaml`), apart from v0's checks. `lint:v1` and CI +fail on any Biome warning under `src/v1/`, and the accessibility rules are +errors there. A PR that is v1 work and needs a v0 change outside the seam +carries the `v0-change` label, which turns the seam check into a report. Biome (`biome.json`) also reports the banned imports and the plain spellings of the v0 ↔ v1 boundary, so `biome ci` and editors flag them too: `@/`, `../`, diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index bb8342b994..789d08e3fc 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -114,7 +114,10 @@ const CASES = parseTable(` v1-raw-pixel-arbitrary-separator | lint:v1 | No raw pixel value under v1 v1-important-utility | lint:v1 | No !important under v1 v1-important-variant-utility | lint:v1 | No !important under v1 - v1-important-css | lint:v1 | No !important under v1 + # Biome now reports CSS !important first (warnings fail v1), so the styling + # policy's own CSS branch is run directly as well. + v1-important-css | lint:v1 | noImportantStyles + v1-important-css | node scripts/check-v1-styles.mjs | No !important under v1 v1-css-import-from-page | lint:v1 | CSS is imported only from app/ and styles/ v1-styles-raw-colour | lint:v1 | No raw colour under v1 v1-styles-raw-colour-named | lint:v1 | No raw colour under v1 @@ -129,6 +132,26 @@ const CASES = parseTable(` v1-cva-unprefixed-variant | lint:v1 | No unprefixed Tailwind utility under v1 v1-styles-policy-ok | lint:v1 | - v1-plain-strings-ok | lint:v1 | - + # Quality (P0-T10): accessibility rules, and every other warning, fail v1. + v1-a11y-button-type | lint:v1 | useButtonType + v1-warning-is-error | lint:v1 | noArrayIndexKey + # Purity (P0-T10): the antd shims are v0, so v1 cannot reach them. + v1-imports-v0-shim | lint:v1 | v1 imports nothing outside src/v1 + # Seam (P0-T10, check-v1-seam.mjs): v1 work changes v0 only at the seam. + seam-v1-only | node scripts/check-v1-seam.mjs --changed changed.txt | - + seam-v1-at-the-seam | node scripts/check-v1-seam.mjs --changed changed.txt | - + seam-v0-fix-only | node scripts/check-v1-seam.mjs --changed changed.txt | - + seam-v1-changes-v0-page | node scripts/check-v1-seam.mjs --changed changed.txt | src/pages/LogsPage.jsx: changes v0 outside the seam + seam-v1-changes-public | node scripts/check-v1-seam.mjs --changed changed.txt | public/favicon.ico: changes v0 outside the seam + seam-v1-tooling-changes-v0 | node scripts/check-v1-seam.mjs --changed changed.txt | antd-button.tsx: changes v0 outside the seam + seam-v1-changes-v0-page | node scripts/check-v1-seam.mjs --changed changed.txt --override | - + # Separation (P0-T10, check-v1-separation.mjs): no plugin code in OSS. + - | node scripts/check-v1-separation.mjs | - + v1-plugin-folder | node scripts/check-v1-separation.mjs | src/v1/plugins/acme/: enterprise plugin code + # Bundle (P0-T10, check-v1-bundle.mjs): real builds, then a saved report. + bundle-v1-clean | node scripts/check-v1-bundle.mjs | - + bundle-v1-pulls-v0 | node scripts/check-v1-bundle.mjs | pulls in v0 module src/legacy.js + bundle-v1-antd-report | node scripts/check-v1-bundle.mjs --report report.json | pulls in antd module `).map(([name, run, fails]) => ({ name: name === "-" ? null : name, run: run.split(" + "), diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-antd-report/report.json b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-antd-report/report.json new file mode 100644 index 0000000000..63cec27e94 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-antd-report/report.json @@ -0,0 +1,22 @@ +{ + "chunks": [ + { + "fileName": "assets/index.js", + "facade": "src/index.jsx", + "modules": ["src/index.jsx"], + "imports": ["assets/logs.js"] + }, + { + "fileName": "assets/logs.js", + "facade": "src/v1/pages/logs/index.tsx", + "modules": ["src/v1/pages/logs/index.tsx"], + "imports": ["assets/vendor.js"] + }, + { + "fileName": "assets/vendor.js", + "facade": null, + "modules": ["/repo/frontend/node_modules/antd/es/button/index.js"], + "imports": [] + } + ] +} diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/index.html b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/index.html new file mode 100644 index 0000000000..47a56ac2c5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/legacy.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/legacy.js new file mode 100644 index 0000000000..85ab98756e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/legacy.js @@ -0,0 +1 @@ +export const legacy = () => "v0"; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/main.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/main.js new file mode 100644 index 0000000000..8407b7749b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/main.js @@ -0,0 +1,4 @@ +import { legacy } from "./legacy.js"; + +legacy(); +import("@v1/pages/demo/index.ts").then((page) => page.render()); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..df95c250c7 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/src/v1/pages/demo/index.ts @@ -0,0 +1 @@ +export const render = () => "v1"; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/vite.config.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/vite.config.js new file mode 100644 index 0000000000..578472658b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-clean/vite.config.js @@ -0,0 +1,6 @@ +import path from "node:path"; + +export default { + resolve: { alias: { "@v1": path.resolve(import.meta.dirname, "src/v1") } }, + build: { outDir: "dist", emptyOutDir: true }, +}; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/index.html b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/index.html new file mode 100644 index 0000000000..47a56ac2c5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/legacy.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/legacy.js new file mode 100644 index 0000000000..85ab98756e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/legacy.js @@ -0,0 +1 @@ +export const legacy = () => "v0"; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/main.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/main.js new file mode 100644 index 0000000000..dd55a7acb7 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/main.js @@ -0,0 +1 @@ +import("@v1/pages/demo/index.ts").then((page) => page.render()); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..1b38b41baf --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/src/v1/pages/demo/index.ts @@ -0,0 +1,3 @@ +import { legacy } from "../../../legacy.js"; + +export const render = () => legacy(); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/vite.config.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/vite.config.js new file mode 100644 index 0000000000..578472658b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-pulls-v0/vite.config.js @@ -0,0 +1,6 @@ +import path from "node:path"; + +export default { + resolve: { alias: { "@v1": path.resolve(import.meta.dirname, "src/v1") } }, + build: { outDir: "dist", emptyOutDir: true }, +}; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v0-fix-only/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v0-fix-only/changed.txt new file mode 100644 index 0000000000..615fba5e11 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v0-fix-only/changed.txt @@ -0,0 +1,2 @@ +src/hooks/useLogout.js +src/hooks/useLogout.test.js diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v1-at-the-seam/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v1-at-the-seam/changed.txt new file mode 100644 index 0000000000..7a23f02fc5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v1-at-the-seam/changed.txt @@ -0,0 +1,4 @@ +src/v1/index.ts +src/routes/Router.jsx +src/index.jsx +src/index.css diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-public/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-public/changed.txt new file mode 100644 index 0000000000..eead5242ba --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-public/changed.txt @@ -0,0 +1,2 @@ +src/v1/assets/logo.svg +public/favicon.ico diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-v0-page/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-v0-page/changed.txt new file mode 100644 index 0000000000..f8c330c4d1 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v1-changes-v0-page/changed.txt @@ -0,0 +1,2 @@ +src/v1/pages/logs/index.tsx +src/pages/LogsPage.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v1-only/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v1-only/changed.txt new file mode 100644 index 0000000000..0247db891c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v1-only/changed.txt @@ -0,0 +1,3 @@ +src/v1/lib/format.ts +package.json +scripts/check-v1.mjs diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v1-tooling-changes-v0/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v1-tooling-changes-v0/changed.txt new file mode 100644 index 0000000000..6874197fb3 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v1-tooling-changes-v0/changed.txt @@ -0,0 +1,2 @@ +scripts/check-v1-styles.mjs +src/components/ui/shims/antd-button.tsx diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-a11y-button-type/src/v1/components/ui/Bad.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-a11y-button-type/src/v1/components/ui/Bad.tsx new file mode 100644 index 0000000000..8f55cfd8b1 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-a11y-button-type/src/v1/components/ui/Bad.tsx @@ -0,0 +1,3 @@ +export function Bad({ onPress }: { onPress: () => void }) { + return ; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-shim/src/v1/components/ui/Button.ts b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-shim/src/v1/components/ui/Button.ts new file mode 100644 index 0000000000..c80a6ebca8 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-shim/src/v1/components/ui/Button.ts @@ -0,0 +1,3 @@ +import { Button } from "@/components/ui/shims/antd-button"; + +export { Button }; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-plugin-folder/src/v1/plugins/acme/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-plugin-folder/src/v1/plugins/acme/index.ts new file mode 100644 index 0000000000..51f0cdda5f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-plugin-folder/src/v1/plugins/acme/index.ts @@ -0,0 +1 @@ +export const acme = true; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-warning-is-error/src/v1/components/ui/List.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-warning-is-error/src/v1/components/ui/List.tsx new file mode 100644 index 0000000000..b437a09258 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-warning-is-error/src/v1/components/ui/List.tsx @@ -0,0 +1,9 @@ +export function List({ items }: { items: string[] }) { + return ( +
    + {items.map((item, index) => ( +
  • {item}
  • + ))} +
+ ); +} From 9aa63fc84a04da26a257a609e2f61d97f02ae5a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 09:23:13 +0000 Subject: [PATCH 2/8] UN-4363 [FIX] Make the v1 guard scripts executable and clear SonarCloud findings - chmod +x on the three new shebang scripts (pre-commit check-shebang-scripts-are-executable). - Explicit compare functions for every sort. - Split the bundle guard's check() into reachable() and describe() to bring its cognitive complexity under the limit; output is unchanged. - git from PATH marked NOSONAR with the reason, as in the fixture runner. - String.raw in the glob escaper; no nested template literal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HzRGGUgsEDiwJ56zKJ4ASv --- frontend/scripts/check-v1-bundle.mjs | 73 +++++++++++++----------- frontend/scripts/check-v1-seam.mjs | 21 +++---- frontend/scripts/check-v1-separation.mjs | 19 +++--- 3 files changed, 58 insertions(+), 55 deletions(-) mode change 100644 => 100755 frontend/scripts/check-v1-bundle.mjs mode change 100644 => 100755 frontend/scripts/check-v1-seam.mjs mode change 100644 => 100755 frontend/scripts/check-v1-separation.mjs diff --git a/frontend/scripts/check-v1-bundle.mjs b/frontend/scripts/check-v1-bundle.mjs old mode 100644 new mode 100755 index 9230187cee..7fa6683cac --- a/frontend/scripts/check-v1-bundle.mjs +++ b/frontend/scripts/check-v1-bundle.mjs @@ -70,6 +70,8 @@ function moduleName(id) { return rel.startsWith("..") ? file : rel; } +const byName = (a, b) => a.localeCompare(b); + function reporter(chunks) { return { name: "v1-bundle-report", @@ -87,8 +89,8 @@ function reporter(chunks) { modules: Object.keys(output.modules) .map(moduleName) .filter(Boolean) - .sort(), - imports: [...output.imports, ...output.dynamicImports].sort(), + .sort(byName), + imports: [...output.imports, ...output.dynamicImports].sort(byName), }); } }, @@ -128,39 +130,46 @@ function isV1Chunk(chunk) { return own.length > 0 && own.every((k) => k === "v1"); } -function check(report) { - const byName = new Map(report.chunks.map((chunk) => [chunk.fileName, chunk])); - const problems = []; - const v1Chunks = report.chunks.filter(isV1Chunk); - for (const root of v1Chunks) { - const seen = new Set(); - const queue = [{ chunk: root, via: [] }]; - while (queue.length > 0) { - const { chunk, via } = queue.shift(); - if (seen.has(chunk.fileName)) { - continue; - } - seen.add(chunk.fileName); - for (const name of chunk.modules) { - const k = kind(name); - if (k === "v0" || k === "antd") { - const where = via.length - ? ` through ${[...via, chunk.fileName].join(" -> ")}` - : ""; - const what = k === "v0" ? "v0 module" : "antd module"; - problems.push( - `${root.fileName} (v1${root.facade ? `, from ${root.facade}` : ""}) pulls in ${what} ${name}${where}.`, - ); - } - } - for (const next of chunk.imports) { - const target = byName.get(next); - if (target) { - queue.push({ chunk: target, via: [...via, chunk.fileName] }); - } +/* Every chunk a v1 chunk loads, itself included, with the chunks in between. */ +function reachable(root, chunksByName) { + const seen = new Map([[root.fileName, []]]); + const queue = [root]; + while (queue.length > 0) { + const chunk = queue.shift(); + const via = [...seen.get(chunk.fileName), chunk.fileName]; + for (const next of chunk.imports) { + const target = chunksByName.get(next); + if (target && !seen.has(next)) { + seen.set(next, via); + queue.push(target); } } } + return [...seen].map(([fileName, via]) => ({ + chunk: chunksByName.get(fileName), + via, + })); +} + +function describe(root, name, path) { + const what = kind(name) === "v0" ? "v0 module" : "antd module"; + const from = root.facade ? `, from ${root.facade}` : ""; + const where = path.length > 1 ? ` through ${path.join(" -> ")}` : ""; + return `${root.fileName} (v1${from}) pulls in ${what} ${name}${where}.`; +} + +function check(report) { + const chunksByName = new Map( + report.chunks.map((chunk) => [chunk.fileName, chunk]), + ); + const v1Chunks = report.chunks.filter(isV1Chunk); + const problems = v1Chunks.flatMap((root) => + reachable(root, chunksByName).flatMap(({ chunk, via }) => + chunk.modules + .filter((name) => ["v0", "antd"].includes(kind(name))) + .map((name) => describe(root, name, [...via, chunk.fileName])), + ), + ); return { v1Chunks: v1Chunks.length, problems }; } diff --git a/frontend/scripts/check-v1-seam.mjs b/frontend/scripts/check-v1-seam.mjs old mode 100644 new mode 100755 index 8c1d832737..c88d3961b8 --- a/frontend/scripts/check-v1-seam.mjs +++ b/frontend/scripts/check-v1-seam.mjs @@ -84,7 +84,7 @@ function globToRegExp(glob) { } else if (ch === "?") { source += "[^/]"; } else { - source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + source += ch.replace(/[.+^${}()|[\]\\]/g, String.raw`\$&`); } } return new RegExp(`^${source}$`); @@ -109,19 +109,12 @@ function readAllowlist() { } function changedFromGit(base) { - const result = spawnSync( - "git", - [ - "diff", - "--name-only", - "--no-renames", - "--relative", - `${base}...HEAD`, - "--", - ".", - ], - { cwd: FRONTEND, encoding: "utf8" }, - ); + const args = ["diff", "--name-only", "--no-renames", "--relative"]; + args.push(`${base}...HEAD`, "--", "."); + const options = { cwd: FRONTEND, encoding: "utf8" }; + // git from PATH on purpose: a developer/CI tool with no fixed location + // across macOS and Linux (as in the boundary fixtures). + const result = spawnSync("git", args, options); // NOSONAR if (result.error || result.status !== 0) { console.error( `check-v1-seam: git diff against '${base}' failed: ${result.error?.message ?? result.stderr.trim()}`, diff --git a/frontend/scripts/check-v1-separation.mjs b/frontend/scripts/check-v1-separation.mjs old mode 100644 new mode 100755 index ec31150194..1084966701 --- a/frontend/scripts/check-v1-separation.mjs +++ b/frontend/scripts/check-v1-separation.mjs @@ -25,12 +25,16 @@ const FRONTEND = path.join(import.meta.dirname, ".."); const PLUGINS_REL = "src/v1/plugins"; const PLUGINS = path.join(FRONTEND, PLUGINS_REL); +/* git from PATH on purpose: a developer/CI tool with no fixed location + * across macOS and Linux (as in the boundary fixtures). */ +function git(args) { + const options = { cwd: FRONTEND, encoding: "utf8" }; + return spawnSync("git", args, options); // NOSONAR +} + function trackedPluginPaths() { // Paths come back relative to the working directory, i.e. frontend/. - const result = spawnSync("git", ["ls-files", "--", PLUGINS_REL], { - cwd: FRONTEND, - encoding: "utf8", - }); + const result = git(["ls-files", "--", PLUGINS_REL]); if (result.error || result.status !== 0) { return null; } @@ -56,10 +60,7 @@ function diskPluginPaths() { } const insideGit = - spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { - cwd: FRONTEND, - encoding: "utf8", - }).stdout.trim() === "true"; + git(["rev-parse", "--is-inside-work-tree"]).stdout?.trim() === "true"; const files = (insideGit && trackedPluginPaths()) || diskPluginPaths(); const plugins = [ @@ -68,7 +69,7 @@ const plugins = [ .filter((file) => file.includes("/")) .map((file) => file.split("/")[0]), ), -].sort(); +].sort((a, b) => a.localeCompare(b)); if (plugins.length === 0) { console.log("check-v1-separation: ok"); From 1422fa740fa287289b6269bdba7b1e23252f10f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 08:50:54 +0000 Subject: [PATCH 3/8] UN-4363 Gate v1 unit tests on the frontend-v1-unit config and upload its coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings over UN-4612 from #2440. The v1 · Unit tests check runs vitest.v1.config.mts, the config tests/groups.yaml gives the rig's frontend-v1-unit group, so a run below v1's coverage threshold fails, and it uploads the coverage report. test-fixtures/v1-unit-gate proves the threshold fails passing tests below it, and that the CI job and the rig group name the same config. test:v1 and the Guard fixtures check run those fixtures too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HzRGGUgsEDiwJ56zKJ4ASv --- .github/workflows/ci-frontend-v1.yaml | 32 +++++-- frontend/package.json | 2 +- frontend/src/v1/README.md | 2 +- .../at-threshold/src/v1/lib/grade.test.ts | 12 +++ .../cases/at-threshold/src/v1/lib/grade.ts | 16 ++++ .../below-threshold/src/v1/lib/grade.test.ts | 6 ++ .../cases/below-threshold/src/v1/lib/grade.ts | 16 ++++ .../v1-unit-gate/unit-gate.fixtures.mjs | 91 +++++++++++++++++++ .../v1-unit-gate/vitest.config.mts | 28 ++++++ 9 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.test.ts create mode 100644 frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.ts create mode 100644 frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.test.ts create mode 100644 frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.ts create mode 100644 frontend/test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs create mode 100644 frontend/test-fixtures/v1-unit-gate/vitest.config.mts diff --git a/.github/workflows/ci-frontend-v1.yaml b/.github/workflows/ci-frontend-v1.yaml index cc0cae2d65..d8d8242a36 100644 --- a/.github/workflows/ci-frontend-v1.yaml +++ b/.github/workflows/ci-frontend-v1.yaml @@ -136,8 +136,12 @@ jobs: working-directory: frontend run: node scripts/check-v1-styles.mjs - # The v1 unit tests under v1's own configs and coverage threshold (P0-T08, - # UN-4329): core, then cloud plugins (none in this repository). + # The v1 unit tests: the rig's `frontend-v1-unit` group (tests/groups.yaml), + # run with the same config so CI and the rig cannot disagree. The config + # fails the run below v1's coverage threshold. Run here rather than through + # ci-test.yaml because its tiers skip frontend-only PRs. Cloud v1 plugins + # (vitest.v1-plugins.config.mts) are gated by the cloud mirror, not here: + # OSS has none. unit: name: v1 · Unit tests needs: changes @@ -159,9 +163,18 @@ jobs: - name: Run v1 unit tests working-directory: frontend # The lockfile's vitest, never a fetched one. - run: | - ./node_modules/.bin/vitest run --config vitest.v1.config.mts --coverage - ./node_modules/.bin/vitest run --config vitest.v1-plugins.config.mts --coverage + run: ./node_modules/.bin/vitest run --config vitest.v1.config.mts + + # The summary is in the log; the lcov report is for reading what a + # failed threshold missed. + - name: Upload v1 coverage + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: frontend-v1-coverage + path: frontend/coverage/v1/ + if-no-files-found: ignore + retention-days: 7 # One production build, then the bundle guard (no v1 chunk loads v0 or # antd) and the separation guard (no enterprise plugin code in this repo). @@ -196,8 +209,11 @@ jobs: working-directory: frontend run: node scripts/check-v1-separation.mjs - # Proves every guard above fails and passes when it should (node --test, - # Node 22 APIs). Spawns the real tools, two small Vite builds included. + # Proves every guard above fails and passes when it should, and that the + # unit harness (src/v1/test) fails a test that sends an unmocked request, + # and that the Unit tests check fails below the coverage threshold and runs + # the rig group's config (node --test, Node 22 APIs). Spawns the real tools, two small + # Vite builds included. guard-fixtures: name: v1 · Guard fixtures needs: changes @@ -223,4 +239,4 @@ jobs: - name: Run the guard fixtures working-directory: frontend - run: node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs + run: node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs diff --git a/frontend/package.json b/frontend/package.json index 057c8b264e..baa3d19eb3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -82,7 +82,7 @@ "tokens:v1": "bun scripts/build-v1-tokens.mjs", "typecheck:v1": "node scripts/check-v1.mjs --files && tsc -p src/v1/tsconfig.json", "lint:v1": "biome lint --error-on-warnings src/v1/ && node scripts/check-v1.mjs --code && node scripts/check-v1-styles.mjs", - "test:v1": "vitest run --config vitest.v1.config.mts --coverage && vitest run --config vitest.v1-plugins.config.mts --coverage && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs", + "test:v1": "vitest run --config vitest.v1.config.mts --coverage && vitest run --config vitest.v1-plugins.config.mts --coverage && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs", "seam:v1": "node scripts/check-v1-seam.mjs --base origin/main", "separation:v1": "node scripts/check-v1-separation.mjs", "bundle:v1": "node scripts/check-v1-bundle.mjs" diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 1b44fed6ed..4296671648 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -114,7 +114,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | `bun run typecheck:v1` | TypeScript-only files, styles only in `styles/`, allowed file types only, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | | `bun run lint:v1` | Biome on `src/v1/` (warnings fail), then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, the single notification entry point, then the styling policy (S1 to S8 above) | | `bun run tokens:v1` | Regenerates `styles/tokens.css` from `styles/tokens.json` (via `styles/tokens.ts`; runs under bun, which imports the TypeScript source directly) | -| `bun run test:v1` | v1 unit tests (`vitest.v1.config.mts`, then cloud plugins with `vitest.v1-plugins.config.mts`) with v1's own coverage threshold, then the guardrail fixtures in `test-fixtures/v1-boundary/` and `test-fixtures/v1-harness/`. The harness is documented in `test/README.md` | +| `bun run test:v1` | v1 unit tests (`vitest.v1.config.mts`, then cloud plugins with `vitest.v1-plugins.config.mts`) with v1's own coverage threshold, then the guardrail fixtures in `test-fixtures/v1-boundary/`, `test-fixtures/v1-harness/` and `test-fixtures/v1-unit-gate/` (the coverage threshold fails a run, and CI runs the rig group's config). The harness is documented in `test/README.md` | | `bun run seam:v1` | Seam guard: v1 work changes v0 (`src/` outside v1, `public/`) only at the files in `scripts/v1-seam-allowlist.json`, compared with `origin/main` | | `bun run separation:v1` | No enterprise plugin code: no folder under `plugins/` is tracked in the public repository | | `bun run bundle:v1` | Production build, then no v1 chunk (or a chunk it loads) holds a v0 module or antd | diff --git a/frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.test.ts b/frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.test.ts new file mode 100644 index 0000000000..59ae1d7b2b --- /dev/null +++ b/frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "vitest"; +import { grade } from "./grade"; + +it.each([ + [95, "A"], + [85, "B"], + [75, "C"], + [65, "D"], + [10, "F"], +])("grades %i as %s", (score, letter) => { + expect(grade(score)).toBe(letter); +}); diff --git a/frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.ts b/frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.ts new file mode 100644 index 0000000000..15ec1244d8 --- /dev/null +++ b/frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.ts @@ -0,0 +1,16 @@ +// Every branch is run by its test. +export function grade(score: number): string { + if (score >= 90) { + return "A"; + } + if (score >= 80) { + return "B"; + } + if (score >= 70) { + return "C"; + } + if (score >= 60) { + return "D"; + } + return "F"; +} diff --git a/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.test.ts b/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.test.ts new file mode 100644 index 0000000000..fb0fed54f9 --- /dev/null +++ b/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.test.ts @@ -0,0 +1,6 @@ +import { expect, it } from "vitest"; +import { grade } from "./grade"; + +it("grades a top score", () => { + expect(grade(95)).toBe("A"); +}); diff --git a/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.ts b/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.ts new file mode 100644 index 0000000000..b8cebaa57b --- /dev/null +++ b/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.ts @@ -0,0 +1,16 @@ +// Half of this file is never run by its test, so line coverage is ~50%. +export function grade(score: number): string { + if (score >= 90) { + return "A"; + } + if (score >= 80) { + return "B"; + } + if (score >= 70) { + return "C"; + } + if (score >= 60) { + return "D"; + } + return "F"; +} diff --git a/frontend/test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs b/frontend/test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs new file mode 100644 index 0000000000..2d5778a653 --- /dev/null +++ b/frontend/test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs @@ -0,0 +1,91 @@ +/* + * Proves the `v1 · Unit tests` check (ci-frontend-v1.yaml) gates what it + * should: it runs the rig's `frontend-v1-unit` group config, and that config + * fails a run below v1's coverage threshold even when every test passes. + * Run by `bun run test:v1` and the `v1 · Guard fixtures` check. + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +const FRONTEND = path.resolve(import.meta.dirname, "..", ".."); +const REPO = path.resolve(FRONTEND, ".."); +const CONFIG = path.join("test-fixtures", "v1-unit-gate", "vitest.config.mts"); +// The lockfile's vitest, run by this same node: nothing resolved from PATH. +const VITEST = path.join(FRONTEND, "node_modules", "vitest", "vitest.mjs"); + +function runCase(name) { + const reports = mkdtempSync(path.join(tmpdir(), "v1-unit-gate-")); + try { + const result = spawnSync( + process.execPath, + [VITEST, "run", "--config", CONFIG], + { + cwd: FRONTEND, + encoding: "utf8", + env: { + ...process.env, + CI: "1", + V1_GATE_CASE: name, + V1_GATE_REPORTS: reports, + }, + }, + ); + const output = `${result.stdout}${result.stderr}`.replace( + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes + /\u001b\[[0-9;]*m/g, + "", + ); + return { status: result.status, output }; + } finally { + rmSync(reports, { recursive: true, force: true }); + } +} + +test("passing tests below the coverage threshold fail the gate", () => { + const { status, output } = runCase("below-threshold"); + assert.notEqual(status, 0, output); + assert.match(output, /Tests\s+1 passed/); + assert.match( + output, + /Coverage for lines \([\d.]+%\) does not meet global threshold \(80%\)/, + ); +}); + +test("passing tests at the coverage threshold pass the gate", () => { + const { status, output } = runCase("at-threshold"); + assert.equal(status, 0, output); +}); + +// The `frontend-v1-unit` block of tests/groups.yaml, up to the next group. +function groupBlock(yaml, name) { + const start = yaml.indexOf(`\n ${name}:\n`); + assert.notEqual(start, -1, `tests/groups.yaml has no ${name} group`); + const rest = yaml.slice(start + name.length + 5); + const end = rest.search(/\n {2}[a-z0-9-]+:\n/); + return end === -1 ? rest : rest.slice(0, end); +} + +test("the CI unit check runs the rig group's vitest config", () => { + const groups = readFileSync(path.join(REPO, "tests", "groups.yaml"), "utf8"); + const config = groupBlock(groups, "frontend-v1-unit").match( + /^\s+vitest_config:\s*(\S+)\s*$/m, + )?.[1]; + assert.ok(config, "frontend-v1-unit sets no vitest_config"); + + const workflow = readFileSync( + path.join(REPO, ".github", "workflows", "ci-frontend-v1.yaml"), + "utf8", + ); + const job = workflow.slice(workflow.indexOf("\n unit:\n")); + assert.ok(job.length > 1, "ci-frontend-v1.yaml has no `unit` job"); + const run = job.match(/^\s+run: (.*vitest run.*)$/m)?.[1]; + assert.ok(run, "the `unit` job runs no vitest"); + assert.ok( + run.includes(`--config ${config}`), + `the \`unit\` job runs \`${run}\`, not the group's config ${config}`, + ); +}); diff --git a/frontend/test-fixtures/v1-unit-gate/vitest.config.mts b/frontend/test-fixtures/v1-unit-gate/vitest.config.mts new file mode 100644 index 0000000000..db1ebf87b3 --- /dev/null +++ b/frontend/test-fixtures/v1-unit-gate/vitest.config.mts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { defineV1Config } from "../../vitest.v1.config.mts"; + +/* + * The real v1 config, coverage threshold included, rooted at one case in + * ./cases (named by V1_GATE_CASE), whose own src/v1 tree stands in for the + * real one. Only the setup file is re-pointed, at the real harness, and the + * report and Vite's cache go to V1_GATE_REPORTS so a run never writes into + * the tree. Used only by unit-gate.fixtures.mjs. + */ +const FRONTEND = path.resolve(import.meta.dirname, "..", ".."); +const CASE = process.env.V1_GATE_CASE ?? ""; +const REPORTS = process.env.V1_GATE_REPORTS ?? ""; +if (!CASE || !REPORTS) { + throw new Error("Set V1_GATE_CASE and V1_GATE_REPORTS"); +} +const base = defineV1Config("core"); + +export default { + ...base, + root: path.join(import.meta.dirname, "cases", CASE), + cacheDir: path.join(REPORTS, "vite"), + test: { + ...base.test, + setupFiles: [path.join(FRONTEND, "src", "v1", "test", "setup.ts")], + coverage: { ...base.test?.coverage, reportsDirectory: REPORTS }, + }, +}; From 80035853f3435113749242f4f2a6cfc810cc902b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 09:38:26 +0000 Subject: [PATCH 4/8] UN-4363 [FIX] Read changed paths unquoted in the seam guard and run v1 checks on their config files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git diff -z keeps a non-ASCII path such as src/pages/Überblick.jsx as written, so it is matched against the seam instead of slipping past both rules quoted. The v1 workflow now also runs when only ci-frontend-v1.yaml or tests/groups.yaml changes, since the unit-gate fixtures read both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HzRGGUgsEDiwJ56zKJ4ASv --- .github/workflows/ci-frontend-v1.yaml | 5 +++++ frontend/scripts/check-v1-seam.mjs | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-frontend-v1.yaml b/.github/workflows/ci-frontend-v1.yaml index d8d8242a36..062add83d2 100644 --- a/.github/workflows/ci-frontend-v1.yaml +++ b/.github/workflows/ci-frontend-v1.yaml @@ -19,6 +19,9 @@ on: - main paths: - "frontend/**" + # The guard fixtures also read these (the unit-gate drift check). + - ".github/workflows/ci-frontend-v1.yaml" + - "tests/groups.yaml" # No `paths` on pull_request: an untriggered workflow reports no check run, # so a required check would wait forever on non-frontend PRs. Filtering is # done by the `changes` job instead; a job skipped via `if:` reports @@ -58,6 +61,8 @@ jobs: filters: | frontend: - "frontend/**" + - ".github/workflows/ci-frontend-v1.yaml" + - "tests/groups.yaml" # Biome's recommended rules and the accessibility rules as errors: biome.json # raises the a11y rules for src/v1, and --error-on-warnings turns every diff --git a/frontend/scripts/check-v1-seam.mjs b/frontend/scripts/check-v1-seam.mjs index c88d3961b8..6723a11ec5 100755 --- a/frontend/scripts/check-v1-seam.mjs +++ b/frontend/scripts/check-v1-seam.mjs @@ -109,7 +109,9 @@ function readAllowlist() { } function changedFromGit(base) { - const args = ["diff", "--name-only", "--no-renames", "--relative"]; + // -z: paths NUL-separated and never quoted, so a non-ASCII path such as + // src/pages/Überblick.jsx is matched as written. + const args = ["diff", "--name-only", "-z", "--no-renames", "--relative"]; args.push(`${base}...HEAD`, "--", "."); const options = { cwd: FRONTEND, encoding: "utf8" }; // git from PATH on purpose: a developer/CI tool with no fixed location @@ -121,7 +123,7 @@ function changedFromGit(base) { ); process.exit(2); } - return result.stdout.split("\n"); + return result.stdout.split("\0"); } function changedFromFile(file) { From bfa93fba9ec226ba69387453d2d145ffa1288d23 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:45:02 +0000 Subject: [PATCH 5/8] UN-4363 [FIX] Judge the bundle by imports and test every guard the way CI runs it Addresses the review on #2434. - Bundle guard: walk the module graph from every src/v1 module (static and dynamic imports) instead of judging chunk placement, so a package shared with v0 no longer fails clean v1 code and a statically mounted v1 entry that imports v0 no longer passes. - Seam guard: read the allowlist from the base branch with --base, guard index.html, count top-level v1 files (vitest.v1.config.mts) as v1 work, and drop src/setupTests.js from the allowlist. - Separation guard: ls-files -z, so a non-ASCII name is read as written. - Fixtures: git-case.sh runs the seam and separation guards in a real repository rooted above frontend/ (renames, deletes, non-ASCII paths, a bad ref, an ignored overlay, a committed plugin, a widened allowlist); real-build bundle cases for a shared package, a static mount and a dynamic v0 import; seam glob near-misses; the 11 a11y rules pinned as errors; each v1 CI job's exact commands pinned; coverage cases just above and below 80%. makeTree leaves out a local cloud overlay. - Docs: the production build covers v1 only once v0 imports it (P0-T03); cloud plugins are for the cloud mirror (UN-4614). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HzRGGUgsEDiwJ56zKJ4ASv --- .github/workflows/ci-frontend-v1.yaml | 9 +- .gitignore | 2 + frontend/scripts/check-v1-bundle.mjs | 134 ++++++++---------- frontend/scripts/check-v1-seam.mjs | 65 ++++++--- frontend/scripts/check-v1-separation.mjs | 15 +- frontend/scripts/v1-seam-allowlist.json | 10 +- .../v1-boundary/boundary.fixtures.mjs | 70 ++++++++- .../cases/bundle-v1-antd-report/report.json | 30 ++-- .../cases/bundle-v1-dynamic-v0/index.html | 6 + .../cases/bundle-v1-dynamic-v0/src/legacy.js | 1 + .../cases/bundle-v1-dynamic-v0/src/main.js | 1 + .../src/v1/pages/demo/index.ts | 2 + .../cases/bundle-v1-dynamic-v0/vite.config.js | 6 + .../cases/bundle-v1-shares-package/index.html | 6 + .../bundle-v1-shares-package/src/legacy.js | 1 + .../bundle-v1-shares-package/src/main.js | 5 + .../src/v1/pages/demo/index.ts | 3 + .../bundle-v1-shares-package/vite.config.js | 6 + .../cases/bundle-v1-static-mount/index.html | 6 + .../bundle-v1-static-mount/src/legacy.js | 1 + .../cases/bundle-v1-static-mount/src/main.js | 3 + .../bundle-v1-static-mount/src/v1/lib/text.ts | 3 + .../src/v1/pages/demo/index.ts | 3 + .../bundle-v1-static-mount/vite.config.js | 6 + .../cases/seam-git-at-the-seam/change.sh | 2 + .../cases/seam-git-at-the-seam/index.html | 1 + .../seam-git-at-the-seam/src/pages/a.jsx | 1 + .../seam-git-at-the-seam/src/routes/index.jsx | 1 + .../seam-git-changes-index-html/change.sh | 2 + .../seam-git-changes-index-html/index.html | 1 + .../src/pages/a.jsx | 1 + .../src/routes/index.jsx | 1 + .../cases/seam-git-deletes-v0/change.sh | 2 + .../cases/seam-git-deletes-v0/index.html | 1 + .../cases/seam-git-deletes-v0/src/pages/a.jsx | 1 + .../seam-git-deletes-v0/src/routes/index.jsx | 1 + .../cases/seam-git-non-ascii/change.sh | 2 + .../cases/seam-git-non-ascii/index.html | 1 + .../cases/seam-git-non-ascii/src/pages/a.jsx | 1 + .../seam-git-non-ascii/src/routes/index.jsx | 1 + .../cases/seam-git-renames-v0/change.sh | 2 + .../cases/seam-git-renames-v0/index.html | 1 + .../cases/seam-git-renames-v0/src/pages/a.jsx | 1 + .../seam-git-renames-v0/src/routes/index.jsx | 1 + .../cases/seam-git-v1-changes-v0/change.sh | 2 + .../cases/seam-git-v1-changes-v0/index.html | 1 + .../seam-git-v1-changes-v0/src/pages/a.jsx | 1 + .../src/routes/index.jsx | 1 + .../cases/seam-git-widens-allowlist/change.sh | 4 + .../seam-git-widens-allowlist/index.html | 1 + .../seam-git-widens-allowlist/src/pages/a.jsx | 1 + .../src/routes/index.jsx | 1 + .../cases/seam-glob-near-misses/changed.txt | 5 + .../cases/seam-glob-nested/changed.txt | 3 + .../cases/seam-v1-root-config/changed.txt | 2 + .../separation-git-non-ascii-file/change.sh | 2 + .../separation-git-overlay-ignored/change.sh | 1 + .../separation-git-plugin-committed/change.sh | 2 + .../test-fixtures/v1-boundary/git-case.sh | 36 +++++ .../src/v1/lib/grade.test.ts | 1 - .../src/v1/lib/grade.ts | 2 +- .../below-threshold/src/v1/lib/grade.test.ts | 8 +- .../cases/below-threshold/src/v1/lib/grade.ts | 2 +- .../v1-unit-gate/unit-gate.fixtures.mjs | 48 ++++++- 64 files changed, 404 insertions(+), 139 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/legacy.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/main.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/v1/pages/demo/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/vite.config.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/legacy.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/main.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/v1/pages/demo/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/vite.config.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/legacy.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/main.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/lib/text.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/pages/demo/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/vite.config.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/change.sh create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/pages/a.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/routes/index.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/change.sh create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/pages/a.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/routes/index.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/change.sh create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/pages/a.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/routes/index.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/change.sh create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/pages/a.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/routes/index.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/change.sh create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/pages/a.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/routes/index.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/change.sh create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/pages/a.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/routes/index.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/change.sh create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/pages/a.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/routes/index.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-glob-near-misses/changed.txt create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-glob-nested/changed.txt create mode 100644 frontend/test-fixtures/v1-boundary/cases/seam-v1-root-config/changed.txt create mode 100644 frontend/test-fixtures/v1-boundary/cases/separation-git-non-ascii-file/change.sh create mode 100644 frontend/test-fixtures/v1-boundary/cases/separation-git-overlay-ignored/change.sh create mode 100644 frontend/test-fixtures/v1-boundary/cases/separation-git-plugin-committed/change.sh create mode 100755 frontend/test-fixtures/v1-boundary/git-case.sh rename frontend/test-fixtures/v1-unit-gate/cases/{at-threshold => above-threshold}/src/v1/lib/grade.test.ts (94%) rename frontend/test-fixtures/v1-unit-gate/cases/{at-threshold => above-threshold}/src/v1/lib/grade.ts (75%) diff --git a/.github/workflows/ci-frontend-v1.yaml b/.github/workflows/ci-frontend-v1.yaml index 062add83d2..0b6d243014 100644 --- a/.github/workflows/ci-frontend-v1.yaml +++ b/.github/workflows/ci-frontend-v1.yaml @@ -10,8 +10,9 @@ name: Frontend v1 checks # re-running these jobs on every label change would cost minutes for nothing. # # This workflow runs on a clean checkout of the public repository, with no -# cloud overlay, so a green Build job is the separation guard's "v1 builds -# with no overlay" half. +# cloud overlay, so green Typecheck, Biome, Purity and Unit tests jobs are the +# separation guard's "v1 works with no overlay" half. The Build job covers v1 +# only once v0 imports it (the version switch, P0-T03). on: push: @@ -145,7 +146,7 @@ jobs: # run with the same config so CI and the rig cannot disagree. The config # fails the run below v1's coverage threshold. Run here rather than through # ci-test.yaml because its tiers skip frontend-only PRs. Cloud v1 plugins - # (vitest.v1-plugins.config.mts) are gated by the cloud mirror, not here: + # (vitest.v1-plugins.config.mts) are for the cloud mirror (UN-4614), not here: # OSS has none. unit: name: v1 · Unit tests @@ -181,7 +182,7 @@ jobs: if-no-files-found: ignore retention-days: 7 - # One production build, then the bundle guard (no v1 chunk loads v0 or + # One production build, then the bundle guard (no v1 module reaches v0 or # antd) and the separation guard (no enterprise plugin code in this repo). build: name: v1 · Build, bundle and separation diff --git a/.gitignore b/.gitignore index 4b391d4453..ab73808bed 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ lib64/ !frontend/src/lib/ !frontend/src/v1/**/lib/ !frontend/test-fixtures/**/lib/ +# The v1 guard fixtures drive git through shell scripts (test-fixtures/v1-boundary). +!frontend/test-fixtures/v1-boundary/**/*.sh parts/ sdist/ var/ diff --git a/frontend/scripts/check-v1-bundle.mjs b/frontend/scripts/check-v1-bundle.mjs index 7fa6683cac..00f422bccc 100755 --- a/frontend/scripts/check-v1-bundle.mjs +++ b/frontend/scripts/check-v1-bundle.mjs @@ -1,23 +1,26 @@ #!/usr/bin/env node /* - * Bundle guard for the frontend v1 epic (P0-T10): a v1 chunk never pulls in - * a v0 module or antd. + * Bundle guard for the frontend v1 epic (P0-T10): no v1 module reaches a v0 + * module or antd in the production module graph. * * Runs the production build (vite.config.js, unchanged) with one extra Rollup - * plugin that records every output chunk: its entry module, the modules - * bundled into it and the chunks it imports. Then, for every v1 chunk, it - * walks the chunks that chunk loads (static and dynamic imports) and fails on - * any module from src/ outside src/v1/ (v0, including the antd shims) or from - * the antd packages. + * plugin that records the module graph Rollup resolved: for every module, the + * modules it imports statically and dynamically. Then, from every module + * under src/v1/, it walks that graph and fails on any module from src/ + * outside src/v1/ (v0, including the antd shims) or from the antd packages. * - * A v1 chunk is one whose entry module is under src/v1/, or whose own source - * modules are all under src/v1/. A chunk that only shares vendor code - * (node_modules) is neither v0 nor v1. Until v1 is loaded through its own - * import (the version switch, P0-T03) the build has no v1 chunk and the guard - * passes with nothing to check; the fixtures prove it fails on a bad tree. + * It judges imports, not chunk placement. Rollup may put a package v1 uses + * into a chunk that also holds v0 code, and v1 may be inlined into a v0 + * chunk; neither changes what v1 code depends on. What the per-file import + * check (check-v1.mjs) cannot see, and this guard can, is a package that + * reaches antd or v0 code transitively. * - * (no flags) Build, then check. The build is also the PR's - * "v1 builds with no overlay" check. + * The graph covers only modules the build reaches. Until v1 is loaded through + * its own import (the version switch, P0-T03) the build reaches no v1 module + * and the guard passes with nothing to check; the fixtures prove it fails on + * a bad tree. + * + * (no flags) Build, then check. * --report Check a saved report instead of building. * --save-report Build, write the report there, then check. */ @@ -72,26 +75,22 @@ function moduleName(id) { const byName = (a, b) => a.localeCompare(b); -function reporter(chunks) { +/* Records { module: [modules it imports] } for every module in the build. */ +function reporter(graph) { return { name: "v1-bundle-report", apply: "build", - generateBundle(_options, bundle) { - for (const output of Object.values(bundle)) { - if (output.type !== "chunk") { + generateBundle() { + for (const id of this.getModuleIds()) { + const name = moduleName(id); + const info = this.getModuleInfo(id); + if (!name || !info) { continue; } - chunks.push({ - fileName: output.fileName, - facade: output.facadeModuleId - ? moduleName(output.facadeModuleId) - : null, - modules: Object.keys(output.modules) - .map(moduleName) - .filter(Boolean) - .sort(byName), - imports: [...output.imports, ...output.dynamicImports].sort(byName), - }); + const deps = [...info.importedIds, ...info.dynamicallyImportedIds] + .map(moduleName) + .filter(Boolean); + graph[name] = [...new Set(deps)].sort(byName); } }, }; @@ -99,14 +98,14 @@ function reporter(chunks) { async function buildReport() { const { build } = await import("vite"); - const chunks = []; + const modules = {}; await build({ root: FRONTEND, configFile: path.join(FRONTEND, "vite.config.js"), logLevel: "warn", - plugins: [reporter(chunks)], + plugins: [reporter(modules)], }); - return { chunks }; + return { modules }; } const kind = (name) => { @@ -122,55 +121,44 @@ const kind = (name) => { return "other"; }; -function isV1Chunk(chunk) { - if (chunk.facade) { - return kind(chunk.facade) === "v1"; - } - const own = chunk.modules.map(kind).filter((k) => k === "v1" || k === "v0"); - return own.length > 0 && own.every((k) => k === "v1"); -} - -/* Every chunk a v1 chunk loads, itself included, with the chunks in between. */ -function reachable(root, chunksByName) { - const seen = new Map([[root.fileName, []]]); +/* Every module reachable from root, with the modules in between. */ +function reachable(root, modules) { + const seen = new Map([[root, []]]); const queue = [root]; while (queue.length > 0) { - const chunk = queue.shift(); - const via = [...seen.get(chunk.fileName), chunk.fileName]; - for (const next of chunk.imports) { - const target = chunksByName.get(next); - if (target && !seen.has(next)) { + const name = queue.shift(); + const via = [...seen.get(name), name]; + for (const next of modules[name] ?? []) { + if (!seen.has(next)) { seen.set(next, via); - queue.push(target); + queue.push(next); } } } - return [...seen].map(([fileName, via]) => ({ - chunk: chunksByName.get(fileName), - via, - })); + return seen; } -function describe(root, name, path) { - const what = kind(name) === "v0" ? "v0 module" : "antd module"; - const from = root.facade ? `, from ${root.facade}` : ""; - const where = path.length > 1 ? ` through ${path.join(" -> ")}` : ""; - return `${root.fileName} (v1${from}) pulls in ${what} ${name}${where}.`; +function describe(target, via) { + const what = kind(target) === "v0" ? "v0 module" : "antd module"; + const where = via.length > 1 ? ` through ${via.slice(1).join(" -> ")}` : ""; + return `${via[0]} (v1) reaches ${what} ${target}${where}.`; } function check(report) { - const chunksByName = new Map( - report.chunks.map((chunk) => [chunk.fileName, chunk]), - ); - const v1Chunks = report.chunks.filter(isV1Chunk); - const problems = v1Chunks.flatMap((root) => - reachable(root, chunksByName).flatMap(({ chunk, via }) => - chunk.modules - .filter((name) => ["v0", "antd"].includes(kind(name))) - .map((name) => describe(root, name, [...via, chunk.fileName])), - ), - ); - return { v1Chunks: v1Chunks.length, problems }; + const modules = report.modules ?? {}; + const v1Modules = Object.keys(modules) + .filter((name) => kind(name) === "v1") + .sort(byName); + // One line per forbidden module, from the first v1 module that reaches it. + const problems = new Map(); + for (const root of v1Modules) { + for (const [target, via] of reachable(root, modules)) { + if (["v0", "antd"].includes(kind(target)) && !problems.has(target)) { + problems.set(target, describe(target, via)); + } + } + } + return { v1Modules: v1Modules.length, problems: [...problems.values()] }; } const opts = parseArgs(process.argv.slice(2)); @@ -191,14 +179,14 @@ if (opts.report) { } } -const { v1Chunks, problems } = check(report); +const { v1Modules, problems } = check(report); if (problems.length > 0) { console.error(problems.join("\n")); console.error( - `\ncheck-v1-bundle: ${problems.length} problem(s). v1 chunks may load only v1 code and packages other than antd.`, + `\ncheck-v1-bundle: ${problems.length} problem(s). v1 code may reach only v1 code and packages other than antd.`, ); process.exit(1); } console.log( - `check-v1-bundle: ok (${report.chunks.length} chunk(s), ${v1Chunks} v1 chunk(s))`, + `check-v1-bundle: ok (${Object.keys(report.modules ?? {}).length} module(s), ${v1Modules} v1 module(s))`, ); diff --git a/frontend/scripts/check-v1-seam.mjs b/frontend/scripts/check-v1-seam.mjs index 6723a11ec5..8fca1403ae 100755 --- a/frontend/scripts/check-v1-seam.mjs +++ b/frontend/scripts/check-v1-seam.mjs @@ -4,13 +4,21 @@ * change v0 only at the seam: the files in scripts/v1-seam-allowlist.json. * * A change set is v1 work when it touches src/v1/ or the v1 tooling - * (scripts/*v1*, test-fixtures/v1-*). Anything else is v0 work, such as a v0 - * bug fix, and passes untouched: the seam limits the epic, not v0. + * (scripts/*v1*, test-fixtures/v1-*, and top-level files such as + * vitest.v1.config.mts). Anything else is v0 work, such as a v0 bug fix, and + * passes untouched: the seam limits the epic, not v0. * - * "v0" here is the code that ships as v0: src/ outside src/v1/, and public/. - * Build, test and CI configuration (package.json, vite.config.js, biome.json, - * scripts/, test-fixtures/, docs/, ...) sits outside both and is never - * guarded. A deleted or renamed v0 file counts as a change to it. + * "v0" here is the code that ships as v0: src/ outside src/v1/, public/, and + * index.html, v0's HTML shell. Build, test and CI configuration + * (package.json, vite.config.js, biome.json, scripts/, test-fixtures/, docs/, + * ...) sits outside both and is never guarded, and so do nginx.conf and + * default.conf: they serve v0 and v1 alike and are reviewed as deployment + * configuration. A deleted or renamed v0 file counts as a change to it. + * + * With --base, the allowlist is read from the base branch, so a PR cannot + * widen the seam and pass in the same change: a wider allowlist applies from + * the next PR, once it is reviewed and merged. Only when the base has no + * allowlist yet (the guard's own introduction) is the PR's copy used. * * --base Changed files are `git diff ...HEAD` (CI passes the * PR's base branch). @@ -26,10 +34,11 @@ import fs from "node:fs"; import path from "node:path"; const FRONTEND = path.join(import.meta.dirname, ".."); -const ALLOWLIST = path.join(import.meta.dirname, "v1-seam-allowlist.json"); -const GUARDED = [/^src\/(?!v1\/)/, /^public\//]; +const ALLOWLIST_REL = "scripts/v1-seam-allowlist.json"; +const GUARDED = [/^src\/(?!v1\/)/, /^public\//, /^index\.html$/]; const V1_WORK = [ /^src\/v1\//, + /^[^/]*v1[^/]*$/, /^scripts\/[^/]*v1[^/]*$/, /^test-fixtures\/v1-[^/]+\//, ]; @@ -90,19 +99,44 @@ function globToRegExp(glob) { return new RegExp(`^${source}$`); } -function readAllowlist() { +/* git from PATH on purpose: a developer/CI tool with no fixed location + * across macOS and Linux (as in the boundary fixtures). */ +function git(args) { + const options = { cwd: FRONTEND, encoding: "utf8" }; + return spawnSync("git", args, options); // NOSONAR +} + +/* The allowlist's text: from the base branch with --base, else the file. */ +function allowlistSource(base) { + if (base) { + const shown = git(["show", `${base}:./${ALLOWLIST_REL}`]); + if (!shown.error && shown.status === 0) { + return { text: shown.stdout, from: `${base}:${ALLOWLIST_REL}` }; + } + } + const file = path.join(FRONTEND, ALLOWLIST_REL); + try { + return { text: fs.readFileSync(file, "utf8"), from: ALLOWLIST_REL }; + } catch (error) { + console.error(`check-v1-seam: cannot read ${file}: ${error.message}`); + process.exit(2); + } +} + +function readAllowlist(base) { + const { text, from } = allowlistSource(base); let parsed; try { - parsed = JSON.parse(fs.readFileSync(ALLOWLIST, "utf8")); + parsed = JSON.parse(text); } catch (error) { - console.error(`check-v1-seam: cannot read ${ALLOWLIST}: ${error.message}`); + console.error(`check-v1-seam: cannot parse ${from}: ${error.message}`); process.exit(2); } if ( !Array.isArray(parsed.allow) || parsed.allow.some((glob) => typeof glob !== "string" || glob === "") ) { - console.error(`check-v1-seam: ${ALLOWLIST} needs an "allow" list of globs`); + console.error(`check-v1-seam: ${from} needs an "allow" list of globs`); process.exit(2); } return parsed.allow.map((glob) => ({ glob, re: globToRegExp(glob) })); @@ -113,10 +147,7 @@ function changedFromGit(base) { // src/pages/Überblick.jsx is matched as written. const args = ["diff", "--name-only", "-z", "--no-renames", "--relative"]; args.push(`${base}...HEAD`, "--", "."); - const options = { cwd: FRONTEND, encoding: "utf8" }; - // git from PATH on purpose: a developer/CI tool with no fixed location - // across macOS and Linux (as in the boundary fixtures). - const result = spawnSync("git", args, options); // NOSONAR + const result = git(args); if (result.error || result.status !== 0) { console.error( `check-v1-seam: git diff against '${base}' failed: ${result.error?.message ?? result.stderr.trim()}`, @@ -136,7 +167,7 @@ function changedFromFile(file) { } const opts = parseArgs(process.argv.slice(2)); -const allow = readAllowlist(); +const allow = readAllowlist(opts.base); const changed = ( opts.base ? changedFromGit(opts.base) : changedFromFile(opts.changed) ) diff --git a/frontend/scripts/check-v1-separation.mjs b/frontend/scripts/check-v1-separation.mjs index 1084966701..1926d7f70c 100755 --- a/frontend/scripts/check-v1-separation.mjs +++ b/frontend/scripts/check-v1-separation.mjs @@ -13,9 +13,11 @@ * a plugin that was committed. Outside git (the fixtures) the folder itself * is read. * - * The other half of the guard, that v1 builds, lints and passes its tests - * with no overlay present, is the v1 CI jobs themselves: they run on a clean - * checkout of this repository, which has no overlay. + * The other half of the guard, that v1 typechecks, lints and passes its + * tests with no overlay present, is the v1 CI jobs themselves: they run on a + * clean checkout of this repository, which has no overlay. The production + * build covers v1 only once v0 imports it (the version switch, P0-T03); + * until then a v1 file nothing imports is not built. */ import { spawnSync } from "node:child_process"; import fs from "node:fs"; @@ -33,13 +35,14 @@ function git(args) { } function trackedPluginPaths() { - // Paths come back relative to the working directory, i.e. frontend/. - const result = git(["ls-files", "--", PLUGINS_REL]); + // Paths come back relative to the working directory, i.e. frontend/, and + // with -z unquoted, so a non-ASCII name is read as written. + const result = git(["ls-files", "-z", "--", PLUGINS_REL]); if (result.error || result.status !== 0) { return null; } return result.stdout - .split("\n") + .split("\0") .filter(Boolean) .map((file) => file.slice(PLUGINS_REL.length + 1)); } diff --git a/frontend/scripts/v1-seam-allowlist.json b/frontend/scripts/v1-seam-allowlist.json index 9ed7c51488..2683f45ede 100644 --- a/frontend/scripts/v1-seam-allowlist.json +++ b/frontend/scripts/v1-seam-allowlist.json @@ -1,10 +1,4 @@ { - "$comment": "v0 files the frontend v1 epic may change until Phase 9 (the seam allowlist in the Frontend v1 spec): route definitions, the root provider mount and the global stylesheet that loads Tailwind and the v1 styles. Build, test and CI configuration outside src/ and public/ is never guarded. The v0 Human Review header is cloud-only, so it is allowed by the cloud copy of this list. Read by scripts/check-v1-seam.mjs; globs are relative to frontend/, `*` stays inside one folder and `**` crosses folders.", - "allow": [ - "src/routes/**", - "src/index.jsx", - "src/App.jsx", - "src/index.css", - "src/setupTests.js" - ] + "$comment": "v0 files the frontend v1 epic may change until Phase 9 (the seam allowlist in the Frontend v1 spec): route definitions, the root provider mount and the global stylesheet that loads Tailwind and the v1 styles. Build, test and CI configuration outside src/, public/ and index.html is never guarded; v0's own test setup (src/setupTests.js) is v0 code, since v1 has its own. With --base the guard reads this list from the base branch, so a change to it applies from the next PR. The v0 Human Review header is cloud-only, so it is allowed by the cloud copy of this list. Read by scripts/check-v1-seam.mjs; globs are relative to frontend/, `*` stays inside one folder and `**` crosses folders.", + "allow": ["src/routes/**", "src/index.jsx", "src/App.jsx", "src/index.css"] } diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index e4987d0fd0..2ed3760fe5 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -149,7 +149,8 @@ const CASES = parseTable(` v1-styles-policy-ok | lint:v1 | - v1-plain-strings-ok | lint:v1 | - # Quality (P0-T10): accessibility rules, and every other warning, fail v1. - v1-a11y-button-type | lint:v1 | useButtonType + # Plain biome lint fails only on errors, so this proves the v1 override. + v1-a11y-button-type | biome lint src/v1/ | useButtonType v1-warning-is-error | lint:v1 | noArrayIndexKey # Purity (P0-T10): the antd shims are v0, so v1 cannot reach them. v1-imports-v0-shim | lint:v1 | v1 imports nothing outside src/v1 @@ -161,13 +162,42 @@ const CASES = parseTable(` seam-v1-changes-public | node scripts/check-v1-seam.mjs --changed changed.txt | public/favicon.ico: changes v0 outside the seam seam-v1-tooling-changes-v0 | node scripts/check-v1-seam.mjs --changed changed.txt | antd-button.tsx: changes v0 outside the seam seam-v1-changes-v0-page | node scripts/check-v1-seam.mjs --changed changed.txt --override | - + # The allowlist globs: nested matches pass, near-misses do not. + seam-glob-nested | node scripts/check-v1-seam.mjs --changed changed.txt | - + seam-glob-near-misses | node scripts/check-v1-seam.mjs --changed changed.txt | frontend/src/routes.jsx: changes v0 + seam-glob-near-misses | node scripts/check-v1-seam.mjs --changed changed.txt | frontend/src/indexXcss: changes v0 + seam-glob-near-misses | node scripts/check-v1-seam.mjs --changed changed.txt | frontend/src/pages/src/App.jsx: changes v0 + seam-glob-near-misses | node scripts/check-v1-seam.mjs --changed changed.txt | frontend/src/routesX/a.jsx: changes v0 + # A top-level v1 file (vitest.v1.config.mts) is v1 work. + seam-v1-root-config | node scripts/check-v1-seam.mjs --changed changed.txt | frontend/src/pages/a.jsx: changes v0 + # --base, as CI runs it: a real repository, root one level above frontend/. + seam-git-v1-changes-v0 | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | frontend/src/pages/a.jsx: changes v0 + seam-git-renames-v0 | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | frontend/src/pages/a.jsx: changes v0 + seam-git-deletes-v0 | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | frontend/src/pages/a.jsx: changes v0 + seam-git-non-ascii | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | frontend/src/pages/Überblick.jsx: changes v0 + seam-git-changes-index-html | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | frontend/index.html: changes v0 + seam-git-at-the-seam | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | - + # The allowlist comes from the base, so widening it in the PR does not help. + seam-git-widens-allowlist | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base base | frontend/src/pages/a.jsx: changes v0 + seam-git-v1-changes-v0 | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-seam.mjs --base nope | git diff against 'nope' failed # Separation (P0-T10, check-v1-separation.mjs): no plugin code in OSS. - | node scripts/check-v1-separation.mjs | - v1-plugin-folder | node scripts/check-v1-separation.mjs | src/v1/plugins/acme/: enterprise plugin code + # In git, as CI runs it: only tracked files count. + - | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-separation.mjs | - + separation-git-overlay-ignored | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-separation.mjs | - + separation-git-plugin-committed | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-separation.mjs | src/v1/plugins/acme/: enterprise plugin code + separation-git-non-ascii-file | sh "$V1_FIXTURES/git-case.sh" node scripts/check-v1-separation.mjs | - # Bundle (P0-T10, check-v1-bundle.mjs): real builds, then a saved report. bundle-v1-clean | node scripts/check-v1-bundle.mjs | - - bundle-v1-pulls-v0 | node scripts/check-v1-bundle.mjs | pulls in v0 module src/legacy.js - bundle-v1-antd-report | node scripts/check-v1-bundle.mjs --report report.json | pulls in antd module + # A package v0 also uses is not v0 code, wherever Rollup places it. + bundle-v1-shares-package | node scripts/check-v1-bundle.mjs | - + bundle-v1-pulls-v0 | node scripts/check-v1-bundle.mjs | reaches v0 module src/legacy.js + bundle-v1-dynamic-v0 | node scripts/check-v1-bundle.mjs | reaches v0 module src/legacy.js + # Mounted statically, so v1 is inlined into a v0 chunk and has none of its own. + bundle-v1-static-mount | node scripts/check-v1-bundle.mjs | src/v1/lib/text.ts (v1) reaches v0 module src/legacy.js + # antd reached through a package, which only this guard can see. + bundle-v1-antd-report | node scripts/check-v1-bundle.mjs --report report.json | reaches antd module /repo/frontend/node_modules/antd/es/button/index.js through /repo/frontend/node_modules/some-table/index.js `).map(([name, run, fails]) => ({ name: name === "-" ? null : name, run: run.split(" + "), @@ -188,8 +218,13 @@ function makeTree(caseName) { fs.cpSync(path.join(FRONTEND, "scripts"), path.join(dir, "scripts"), { recursive: true, }); + // Without a local copy of the cloud overlay (ignored by design, D7), so a + // developer who has one gets the same results as CI. + const plugins = path.join(FRONTEND, "src", "v1", "plugins"); fs.cpSync(path.join(FRONTEND, "src", "v1"), path.join(dir, "src", "v1"), { recursive: true, + filter: (src) => + path.dirname(src) !== plugins || !fs.statSync(src).isDirectory(), }); fs.symlinkSync( path.join(FRONTEND, "node_modules"), @@ -233,6 +268,8 @@ function runAll(dir, run) { env: { ...process.env, NO_COLOR: "1", + // For steps that run git-case.sh, which is not copied into the tree. + V1_FIXTURES: import.meta.dirname, PATH: `${path.join(dir, "node_modules", ".bin")}${path.delimiter}${process.env.PATH}`, }, }); @@ -313,6 +350,33 @@ test("cloud v1 plugins are git-ignored; the public manifest is not", () => { assert.deepEqual(sorted(ignored), sorted(cloud)); }); +// The accessibility rules the v1 README and the PR promise as errors. +const V1_A11Y_ERRORS = [ + "noLabelWithoutControl", + "noNoninteractiveElementInteractions", + "noRedundantRoles", + "noStaticElementInteractions", + "useAriaPropsSupportedByRole", + "useButtonType", + "useFocusableInteractive", + "useGenericFontNames", + "useKeyWithClickEvents", + "useSemanticElements", + "useValidAnchor", +]; + +test("the accessibility rules are errors under src/v1", () => { + const biome = JSON.parse( + fs.readFileSync(path.join(FRONTEND, "biome.json"), "utf8"), + ); + const v1 = biome.overrides.find((o) => o.includes?.includes("src/v1/**")); + assert.ok(v1, "biome.json has no src/v1/** override"); + const a11y = v1.linter?.rules?.a11y ?? {}; + for (const rule of V1_A11Y_ERRORS) { + assert.equal(a11y[rule], "error", `a11y/${rule} under src/v1`); + } +}); + test("every case folder is listed", () => { const listed = new Set(CASES.map((c) => c.name).filter(Boolean)); assert.deepEqual(sorted(fs.readdirSync(CASES_DIR)), sorted([...listed])); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-antd-report/report.json b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-antd-report/report.json index 63cec27e94..d7fbcaf37b 100644 --- a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-antd-report/report.json +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-antd-report/report.json @@ -1,22 +1,12 @@ { - "chunks": [ - { - "fileName": "assets/index.js", - "facade": "src/index.jsx", - "modules": ["src/index.jsx"], - "imports": ["assets/logs.js"] - }, - { - "fileName": "assets/logs.js", - "facade": "src/v1/pages/logs/index.tsx", - "modules": ["src/v1/pages/logs/index.tsx"], - "imports": ["assets/vendor.js"] - }, - { - "fileName": "assets/vendor.js", - "facade": null, - "modules": ["/repo/frontend/node_modules/antd/es/button/index.js"], - "imports": [] - } - ] + "modules": { + "src/index.jsx": ["src/v1/pages/logs/index.tsx"], + "src/v1/pages/logs/index.tsx": [ + "/repo/frontend/node_modules/some-table/index.js" + ], + "/repo/frontend/node_modules/some-table/index.js": [ + "/repo/frontend/node_modules/antd/es/button/index.js" + ], + "/repo/frontend/node_modules/antd/es/button/index.js": [] + } } diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/index.html b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/index.html new file mode 100644 index 0000000000..47a56ac2c5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/legacy.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/legacy.js new file mode 100644 index 0000000000..85ab98756e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/legacy.js @@ -0,0 +1 @@ +export const legacy = () => "v0"; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/main.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/main.js new file mode 100644 index 0000000000..dd55a7acb7 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/main.js @@ -0,0 +1 @@ +import("@v1/pages/demo/index.ts").then((page) => page.render()); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..df0e672fd5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/src/v1/pages/demo/index.ts @@ -0,0 +1,2 @@ +export const render = () => + import("../../../legacy.js").then(({ legacy }) => legacy()); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/vite.config.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/vite.config.js new file mode 100644 index 0000000000..578472658b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-dynamic-v0/vite.config.js @@ -0,0 +1,6 @@ +import path from "node:path"; + +export default { + resolve: { alias: { "@v1": path.resolve(import.meta.dirname, "src/v1") } }, + build: { outDir: "dist", emptyOutDir: true }, +}; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/index.html b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/index.html new file mode 100644 index 0000000000..47a56ac2c5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/legacy.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/legacy.js new file mode 100644 index 0000000000..85ab98756e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/legacy.js @@ -0,0 +1 @@ +export const legacy = () => "v0"; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/main.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/main.js new file mode 100644 index 0000000000..73691b351b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/main.js @@ -0,0 +1,5 @@ +import clsx from "clsx"; +import { legacy } from "./legacy.js"; + +document.body.className = clsx("v0", legacy()); +import("@v1/pages/demo/index.ts").then((page) => page.render()); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..b20ed870af --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/src/v1/pages/demo/index.ts @@ -0,0 +1,3 @@ +import clsx from "clsx"; + +export const render = () => clsx("v1", "page"); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/vite.config.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/vite.config.js new file mode 100644 index 0000000000..578472658b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-shares-package/vite.config.js @@ -0,0 +1,6 @@ +import path from "node:path"; + +export default { + resolve: { alias: { "@v1": path.resolve(import.meta.dirname, "src/v1") } }, + build: { outDir: "dist", emptyOutDir: true }, +}; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/index.html b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/index.html new file mode 100644 index 0000000000..47a56ac2c5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/legacy.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/legacy.js new file mode 100644 index 0000000000..85ab98756e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/legacy.js @@ -0,0 +1 @@ +export const legacy = () => "v0"; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/main.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/main.js new file mode 100644 index 0000000000..7cae144575 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/main.js @@ -0,0 +1,3 @@ +import { render } from "@v1/pages/demo/index.ts"; + +render(); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/lib/text.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/lib/text.ts new file mode 100644 index 0000000000..bf307a1fa0 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/lib/text.ts @@ -0,0 +1,3 @@ +import { legacy } from "../../legacy.js"; + +export const text = () => legacy(); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..f34961e6dc --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/src/v1/pages/demo/index.ts @@ -0,0 +1,3 @@ +import { text } from "@v1/lib/text.ts"; + +export const render = () => text(); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/vite.config.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/vite.config.js new file mode 100644 index 0000000000..578472658b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-static-mount/vite.config.js @@ -0,0 +1,6 @@ +import path from "node:path"; + +export default { + resolve: { alias: { "@v1": path.resolve(import.meta.dirname, "src/v1") } }, + build: { outDir: "dist", emptyOutDir: true }, +}; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/change.sh new file mode 100644 index 0000000000..f7d5fe1231 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +echo '// changed' >> src/routes/index.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-at-the-seam/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/change.sh new file mode 100644 index 0000000000..c1eb18362e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +echo '' >> index.html diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-changes-index-html/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/change.sh new file mode 100644 index 0000000000..3ce1b03bc4 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +git rm -q src/pages/a.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-deletes-v0/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/change.sh new file mode 100644 index 0000000000..2f040fe5db --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +echo 'export const U = 1;' > 'src/pages/Überblick.jsx' diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-non-ascii/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/change.sh new file mode 100644 index 0000000000..1f29805bda --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +git mv src/pages/a.jsx src/pages/b.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-renames-v0/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/change.sh new file mode 100644 index 0000000000..a5782df989 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +echo '// changed' >> src/pages/a.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-v1-changes-v0/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/change.sh new file mode 100644 index 0000000000..3e7300a40e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/change.sh @@ -0,0 +1,4 @@ +mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts +echo '// changed' >> src/pages/a.jsx +sed -i 's#"src/routes/\*\*",#"src/routes/**", "src/pages/**",#' scripts/v1-seam-allowlist.json +grep -q 'src/pages' scripts/v1-seam-allowlist.json diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/index.html b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/index.html new file mode 100644 index 0000000000..c50eddd41f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/index.html @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/pages/a.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/pages/a.jsx new file mode 100644 index 0000000000..38db18e938 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/pages/a.jsx @@ -0,0 +1 @@ +export const A = () => null; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/routes/index.jsx b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/routes/index.jsx new file mode 100644 index 0000000000..0cfe561a59 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/src/routes/index.jsx @@ -0,0 +1 @@ +export const routes = []; diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-glob-near-misses/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-glob-near-misses/changed.txt new file mode 100644 index 0000000000..fcef155c3b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-glob-near-misses/changed.txt @@ -0,0 +1,5 @@ +src/v1/lib/x.ts +src/routes.jsx +src/indexXcss +src/pages/src/App.jsx +src/routesX/a.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-glob-nested/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-glob-nested/changed.txt new file mode 100644 index 0000000000..b1f40fcade --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-glob-nested/changed.txt @@ -0,0 +1,3 @@ +src/v1/lib/x.ts +src/routes/settings/deep/Route.jsx +src/index.css diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-v1-root-config/changed.txt b/frontend/test-fixtures/v1-boundary/cases/seam-v1-root-config/changed.txt new file mode 100644 index 0000000000..3d5c0d4f2d --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/seam-v1-root-config/changed.txt @@ -0,0 +1,2 @@ +vitest.v1.config.mts +src/pages/a.jsx diff --git a/frontend/test-fixtures/v1-boundary/cases/separation-git-non-ascii-file/change.sh b/frontend/test-fixtures/v1-boundary/cases/separation-git-non-ascii-file/change.sh new file mode 100644 index 0000000000..125c06628b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/separation-git-non-ascii-file/change.sh @@ -0,0 +1,2 @@ +echo 'export {};' > 'src/v1/plugins/Ü.ts' +git add -f 'src/v1/plugins/Ü.ts' diff --git a/frontend/test-fixtures/v1-boundary/cases/separation-git-overlay-ignored/change.sh b/frontend/test-fixtures/v1-boundary/cases/separation-git-overlay-ignored/change.sh new file mode 100644 index 0000000000..f47bf06387 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/separation-git-overlay-ignored/change.sh @@ -0,0 +1 @@ +mkdir -p src/v1/plugins/acme && echo "export {};" > src/v1/plugins/acme/index.ts diff --git a/frontend/test-fixtures/v1-boundary/cases/separation-git-plugin-committed/change.sh b/frontend/test-fixtures/v1-boundary/cases/separation-git-plugin-committed/change.sh new file mode 100644 index 0000000000..f81d731aa6 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/separation-git-plugin-committed/change.sh @@ -0,0 +1,2 @@ +mkdir -p src/v1/plugins/acme && echo "export {};" > src/v1/plugins/acme/index.ts +git add -f src/v1/plugins/acme/index.ts diff --git a/frontend/test-fixtures/v1-boundary/git-case.sh b/frontend/test-fixtures/v1-boundary/git-case.sh new file mode 100755 index 0000000000..82fc291016 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/git-case.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Runs a guard ("$@") the way CI does: inside a real git repository whose root +# is one level above frontend/, so paths, --relative, renames, quoting and +# .gitignore all behave as on a PR. Used by boundary.fixtures.mjs. +# +# The case's tree (the current directory) is committed as the base, on +# branch `base`. Then the case's change.sh, if any, runs in frontend/ and its +# result is committed on top. change.sh itself is part of the base commit, so +# it is never part of the change. +set -eu +here=$(pwd) +repo=$(mktemp -d) +trap 'rm -rf "$repo"' EXIT +mkdir "$repo/frontend" +for entry in "$here"/* "$here"/.gitignore; do + [ "$(basename "$entry")" = node_modules ] || cp -R "$entry" "$repo/frontend/" +done +ln -s "$here/node_modules" "$repo/frontend/node_modules" +# The repository's own rule for the cloud overlay (D7). +printf '%s\n' 'frontend/src/v1/plugins/*' '!frontend/src/v1/plugins/manifest.ts' \ + >"$repo/.gitignore" +cd "$repo" +git init -q +git config user.email fixtures@example.com +git config user.name fixtures +git config core.quotePath true +git add -A +git commit -q -m base +git branch base +if [ -f frontend/change.sh ]; then + (cd frontend && sh change.sh) +fi +git add -A +git commit -q --allow-empty -m change +cd frontend +"$@" diff --git a/frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.test.ts b/frontend/test-fixtures/v1-unit-gate/cases/above-threshold/src/v1/lib/grade.test.ts similarity index 94% rename from frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.test.ts rename to frontend/test-fixtures/v1-unit-gate/cases/above-threshold/src/v1/lib/grade.test.ts index 59ae1d7b2b..a44c907f97 100644 --- a/frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.test.ts +++ b/frontend/test-fixtures/v1-unit-gate/cases/above-threshold/src/v1/lib/grade.test.ts @@ -6,7 +6,6 @@ it.each([ [85, "B"], [75, "C"], [65, "D"], - [10, "F"], ])("grades %i as %s", (score, letter) => { expect(grade(score)).toBe(letter); }); diff --git a/frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.ts b/frontend/test-fixtures/v1-unit-gate/cases/above-threshold/src/v1/lib/grade.ts similarity index 75% rename from frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.ts rename to frontend/test-fixtures/v1-unit-gate/cases/above-threshold/src/v1/lib/grade.ts index 15ec1244d8..bd7fe9358c 100644 --- a/frontend/test-fixtures/v1-unit-gate/cases/at-threshold/src/v1/lib/grade.ts +++ b/frontend/test-fixtures/v1-unit-gate/cases/above-threshold/src/v1/lib/grade.ts @@ -1,4 +1,4 @@ -// Every branch is run by its test. +// One branch (the F grade) is never run: ~87% of lines, just above 80%. export function grade(score: number): string { if (score >= 90) { return "A"; diff --git a/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.test.ts b/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.test.ts index fb0fed54f9..9734321c8c 100644 --- a/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.test.ts +++ b/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.test.ts @@ -1,6 +1,10 @@ import { expect, it } from "vitest"; import { grade } from "./grade"; -it("grades a top score", () => { - expect(grade(95)).toBe("A"); +it.each([ + [95, "A"], + [85, "B"], + [75, "C"], +])("grades %i as %s", (score, letter) => { + expect(grade(score)).toBe(letter); }); diff --git a/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.ts b/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.ts index b8cebaa57b..ca6a23fbad 100644 --- a/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.ts +++ b/frontend/test-fixtures/v1-unit-gate/cases/below-threshold/src/v1/lib/grade.ts @@ -1,4 +1,4 @@ -// Half of this file is never run by its test, so line coverage is ~50%. +// Two branches (D and F) are never run: ~67% of lines, just below 80%. export function grade(score: number): string { if (score >= 90) { return "A"; diff --git a/frontend/test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs b/frontend/test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs index 2d5778a653..431820f527 100644 --- a/frontend/test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs +++ b/frontend/test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs @@ -48,15 +48,15 @@ function runCase(name) { test("passing tests below the coverage threshold fail the gate", () => { const { status, output } = runCase("below-threshold"); assert.notEqual(status, 0, output); - assert.match(output, /Tests\s+1 passed/); + assert.match(output, /Tests\s+3 passed/); assert.match( output, /Coverage for lines \([\d.]+%\) does not meet global threshold \(80%\)/, ); }); -test("passing tests at the coverage threshold pass the gate", () => { - const { status, output } = runCase("at-threshold"); +test("passing tests just above the coverage threshold pass the gate", () => { + const { status, output } = runCase("above-threshold"); assert.equal(status, 0, output); }); @@ -89,3 +89,45 @@ test("the CI unit check runs the rig group's vitest config", () => { `the \`unit\` job runs \`${run}\`, not the group's config ${config}`, ); }); + +// Each v1 job's commands, exactly: a job that runs something weaker (no +// --error-on-warnings, `|| true`, a guard swapped for `true`) fails here. +const JOB_COMMANDS = { + biome: ["biome ci --error-on-warnings src/v1/"], + typecheck: ["bun run typecheck:v1"], + purity: [ + "node scripts/check-v1.mjs --code", + "node scripts/check-v1-styles.mjs", + ], + unit: ["./node_modules/.bin/vitest run --config vitest.v1.config.mts"], + build: [ + "node scripts/check-v1-bundle.mjs", + "node scripts/check-v1-separation.mjs", + ], + "guard-fixtures": [ + "node --test test-fixtures/v1-boundary/boundary.fixtures.mjs test-fixtures/v1-harness/harness.fixtures.mjs test-fixtures/v1-unit-gate/unit-gate.fixtures.mjs", + ], +}; + +// A job's block: from ` :` to the next top-level job. +function jobBlock(workflow, name) { + const start = workflow.indexOf(`\n ${name}:\n`); + assert.notEqual(start, -1, `ci-frontend-v1.yaml has no \`${name}\` job`); + const rest = workflow.slice(start + name.length + 5); + const end = rest.search(/\n {2}[a-z0-9-]+:\n/); + return end === -1 ? rest : rest.slice(0, end); +} + +test("each v1 CI job runs its guard's exact commands", () => { + const workflow = readFileSync( + path.join(REPO, ".github", "workflows", "ci-frontend-v1.yaml"), + "utf8", + ); + assert.doesNotMatch(workflow, /continue-on-error/); + for (const [name, commands] of Object.entries(JOB_COMMANDS)) { + const runs = [...jobBlock(workflow, name).matchAll(/^\s+run: (.*)$/gm)] + .map((match) => match[1].trim()) + .filter((run) => !run.startsWith("bun install")); + assert.deepEqual(runs, commands, `the \`${name}\` job's run steps`); + } +}); From 561288591a022e159406b0cf4e5097b6837db79c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:48:56 +0000 Subject: [PATCH 6/8] UN-4363 [FIX] Widen the allowlist with node in the seam fixture, so it runs on macOS BSD sed has no GNU-style `sed -i`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HzRGGUgsEDiwJ56zKJ4ASv --- .../cases/seam-git-widens-allowlist/change.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/change.sh b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/change.sh index 3e7300a40e..a7ed340a2c 100644 --- a/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/change.sh +++ b/frontend/test-fixtures/v1-boundary/cases/seam-git-widens-allowlist/change.sh @@ -1,4 +1,11 @@ mkdir -p src/v1/lib && echo "export const x = 1;" > src/v1/lib/x.ts echo '// changed' >> src/pages/a.jsx -sed -i 's#"src/routes/\*\*",#"src/routes/**", "src/pages/**",#' scripts/v1-seam-allowlist.json +# Widen the allowlist in the same change (node, not `sed -i`, for macOS). +node -e ' +const fs = require("node:fs"); +const file = "scripts/v1-seam-allowlist.json"; +const list = JSON.parse(fs.readFileSync(file, "utf8")); +list.allow.push("src/pages/**"); +fs.writeFileSync(file, JSON.stringify(list, null, 2)); +' grep -q 'src/pages' scripts/v1-seam-allowlist.json From 40d7fc78c1564000530ba65cc12e364f30993c68 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 06:20:09 +0000 Subject: [PATCH 7/8] UN-4363 [FEAT] Suppress a false-positive hook-dependency warning in the CodeEditor test mock The v1 lint now fails on warnings. Biome flags loads as unnecessary, but it is read per render from a mutable mock that the tests flip between renders. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HzRGGUgsEDiwJ56zKJ4ASv --- frontend/src/v1/components/ui/code/CodeEditor.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/v1/components/ui/code/CodeEditor.test.tsx b/frontend/src/v1/components/ui/code/CodeEditor.test.tsx index cf32723a94..8181503fd9 100644 --- a/frontend/src/v1/components/ui/code/CodeEditor.test.tsx +++ b/frontend/src/v1/components/ui/code/CodeEditor.test.tsx @@ -39,6 +39,7 @@ vi.mock("@monaco-editor/react", () => ({ seen.push(props); // Reports a mount once the runtime "loads", as Monaco does. const loads = monaco.loads; + // biome-ignore lint/correctness/useExhaustiveDependencies: loads is read per render from a mutable mock, so the effect must re-run when a test flips it. useEffect(() => { if (loads) { props.onMount?.(); From 082c95ad03bf704082ac3cdfea0e7483e814452a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 09:09:56 +0000 Subject: [PATCH 8/8] UN-4363 [FEAT] Follow CommonJS wrapper modules in the v1 bundle guard Rollup reaches a CommonJS module through a virtual "\0?commonjs-..." module. The guard dropped every virtual id, cutting the chain, so v1 could reach v0 or antd through a CommonJS package unnoticed. Virtual modules now stay in the graph, named after the file they wrap (edges merged); ones with no file behind them are "virtual:" and never count as v1, v0 or antd. New fixture bundle-v1-cjs-v0 (v1 -> CommonJS lib -> v0) fails as it should. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HzRGGUgsEDiwJ56zKJ4ASv --- frontend/scripts/check-v1-bundle.mjs | 20 ++++++++++++++----- .../v1-boundary/boundary.fixtures.mjs | 2 ++ .../cases/bundle-v1-cjs-v0/index.html | 6 ++++++ .../cases/bundle-v1-cjs-v0/src/legacy.cjs | 1 + .../cases/bundle-v1-cjs-v0/src/main.js | 1 + .../src/v1/pages/demo/index.ts | 3 +++ .../bundle-v1-cjs-v0/vendor/cjs-lib/index.js | 3 +++ .../cases/bundle-v1-cjs-v0/vite.config.js | 12 +++++++++++ 8 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/index.html create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/src/legacy.cjs create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/src/main.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/src/v1/pages/demo/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/vendor/cjs-lib/index.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/vite.config.js diff --git a/frontend/scripts/check-v1-bundle.mjs b/frontend/scripts/check-v1-bundle.mjs index 00f422bccc..86c6849bb6 100755 --- a/frontend/scripts/check-v1-bundle.mjs +++ b/frontend/scripts/check-v1-bundle.mjs @@ -60,12 +60,19 @@ function parseArgs(argv) { } /* A module id as the report keeps it: frontend-relative for our sources, - * as-is (absolute) for packages, null for virtual modules. */ + * as-is (absolute) for packages. Virtual modules stay in the graph, because + * edges run through them: Rollup reaches a CommonJS module through + * "\0?commonjs-…", which is named after so the walk continues + * into it. One with no file behind it ("\0commonjsHelpers.js") is + * "virtual:", never v1, v0 or antd. */ function moduleName(id) { - if (id.startsWith("\0")) { + const file = id.replace(/^\0/, "").split("?")[0]; + if (!file) { return null; } - const file = id.split("?")[0]; + if (!path.isAbsolute(file)) { + return `virtual:${file}`; + } if (file.includes("/node_modules/")) { return file; } @@ -89,8 +96,11 @@ function reporter(graph) { } const deps = [...info.importedIds, ...info.dynamicallyImportedIds] .map(moduleName) - .filter(Boolean); - graph[name] = [...new Set(deps)].sort(byName); + .filter((dep) => dep && dep !== name); + // A file and its virtual wrapper share a name, so merge their edges. + graph[name] = [...new Set([...(graph[name] ?? []), ...deps])].sort( + byName, + ); } }, }; diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index 206fa440f4..fcc736bba7 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -216,6 +216,8 @@ const CASES = parseTable(` bundle-v1-shares-package | node scripts/check-v1-bundle.mjs | - bundle-v1-pulls-v0 | node scripts/check-v1-bundle.mjs | reaches v0 module src/legacy.js bundle-v1-dynamic-v0 | node scripts/check-v1-bundle.mjs | reaches v0 module src/legacy.js + # Through a CommonJS module, which Rollup reaches via a virtual \0 module. + bundle-v1-cjs-v0 | node scripts/check-v1-bundle.mjs | reaches v0 module src/legacy.cjs # Mounted statically, so v1 is inlined into a v0 chunk and has none of its own. bundle-v1-static-mount | node scripts/check-v1-bundle.mjs | src/v1/lib/text.ts (v1) reaches v0 module src/legacy.js # antd reached through a package, which only this guard can see. diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/index.html b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/index.html new file mode 100644 index 0000000000..47a56ac2c5 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/index.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/src/legacy.cjs b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/src/legacy.cjs new file mode 100644 index 0000000000..b76fbf468a --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/src/legacy.cjs @@ -0,0 +1 @@ +module.exports = { legacy: () => "v0" }; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/src/main.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/src/main.js new file mode 100644 index 0000000000..dd55a7acb7 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/src/main.js @@ -0,0 +1 @@ +import("@v1/pages/demo/index.ts").then((page) => page.render()); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/src/v1/pages/demo/index.ts b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/src/v1/pages/demo/index.ts new file mode 100644 index 0000000000..90f669f15c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/src/v1/pages/demo/index.ts @@ -0,0 +1,3 @@ +import lib from "../../../../vendor/cjs-lib/index.js"; + +export const render = () => lib.wrap(); diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/vendor/cjs-lib/index.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/vendor/cjs-lib/index.js new file mode 100644 index 0000000000..70484e7e96 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/vendor/cjs-lib/index.js @@ -0,0 +1,3 @@ +const { legacy } = require("../../src/legacy.cjs"); + +module.exports = { wrap: () => legacy() }; diff --git a/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/vite.config.js b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/vite.config.js new file mode 100644 index 0000000000..7154811d0c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/bundle-v1-cjs-v0/vite.config.js @@ -0,0 +1,12 @@ +import path from "node:path"; + +// vendor/cjs-lib stands in for a CommonJS package: Rollup reaches it through a +// virtual "\0…?commonjs-…" module, the edge the guard must follow. +export default { + resolve: { alias: { "@v1": path.resolve(import.meta.dirname, "src/v1") } }, + build: { + outDir: "dist", + emptyOutDir: true, + commonjsOptions: { include: [/vendor\/cjs-lib\//, /src\/legacy\.cjs$/] }, + }, +};