From 655135d45da790d8ce00fa86d133486ed947c39c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 06:53:06 +0000 Subject: [PATCH 1/2] 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/2] 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");