From 0c5bacb570c5fa2c32e5e51a79111fd5cf1f54df Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Mon, 26 Oct 2026 17:51:22 +0530 Subject: [PATCH 1/6] feat(frontend): v1 scaffold, strict TypeScript and import boundary (UN-4270) Adds frontend/src/v1/ as an isolated tree for the v1 frontend (P0-T01): - Folder taxonomy from the spec, a public entry (src/v1/index.ts, imported by v0 as `@v1`) and a cloud overlay placeholder. - src/v1/tsconfig.json: strict, no JS, unused locals/params are errors, `@v1` alias only. v0's tsconfig/jsconfig exclude src/v1. - Biome overrides for the two-way import boundary and v1 bans (no `../`, `@/`, prop-types, antd, component CSS; noExplicitAny). - scripts/check-v1.mjs for what Biome cannot express: TypeScript-only files, the folder taxonomy and import contract, PropTypes, `ant-`. - typecheck:v1, lint:v1 and test:v1 scripts; v0 scripts are unchanged. - test-fixtures/v1-boundary/ proves each rule fails and passes as specified, run with node --test so the v0 vitest suite never sees it. - .gitignore re-includes src/v1/lib/ (the Python `lib/` rule matched it). v0 build output, test counts, typecheck and Biome results are unchanged. --- .gitignore | 6 +- .pre-commit-config.yaml | 4 +- frontend/biome.json | 59 +++- frontend/jsconfig.json | 5 +- frontend/package.json | 5 +- frontend/scripts/check-v1.mjs | 253 ++++++++++++++++++ frontend/src/v1/README.md | 49 ++++ frontend/src/v1/app/.gitkeep | 0 frontend/src/v1/components/patterns/.gitkeep | 0 frontend/src/v1/components/ui/.gitkeep | 0 frontend/src/v1/hooks/.gitkeep | 0 frontend/src/v1/index.ts | 7 + frontend/src/v1/lib/.gitkeep | 0 frontend/src/v1/pages/.gitkeep | 0 frontend/src/v1/plugins/manifest.ts | 6 + frontend/src/v1/services/.gitkeep | 0 frontend/src/v1/shell/.gitkeep | 0 frontend/src/v1/stores/.gitkeep | 0 frontend/src/v1/styles/.gitkeep | 0 frontend/src/v1/test/.gitkeep | 0 frontend/src/v1/tsconfig.json | 39 +++ .../v1-boundary/boundary.fixtures.mjs | 220 +++++++++++++++ .../v0-imports-v1-entry/src/pages/Ok.jsx | 1 + .../v0-imports-v1-internal/src/pages/Bad.jsx | 3 + .../v0-imports-v1-relative/src/pages/Bad.jsx | 3 + .../src/v1/components/ui/Label.test.ts | 4 + .../src/v1/components/ui/Label.ts | 3 + .../v1-allowed-imports/src/v1/lib/format.ts | 1 + .../src/v1/pages/projects/title.ts | 1 + .../src/v1/pages/projects/view/heading.ts | 4 + .../v1-allowed-imports/src/v1/test/render.ts | 1 + .../src/v1/components/ui/Button.ts | 1 + .../src/v1/lib/bad.ts | 1 + .../src/v1/components/ui/Badge.css | 3 + .../src/v1/components/ui/Badge.tsx | 3 + .../cases/v1-explicit-any/src/v1/lib/bad.ts | 1 + .../cases/v1-implicit-any/src/v1/lib/bad.ts | 3 + .../v1-imports-v0-alias/src/v1/lib/bad.ts | 3 + .../v1-imports-v0-relative/src/v1/lib/bad.ts | 3 + .../cases/v1-js-file/src/v1/lib/legacy.js | 1 + .../src/v1/widgets/thing.ts | 1 + .../src/v1/pages/projects/index.ts | 3 + .../src/v1/pages/settings/index.ts | 1 + .../src/v1/components/ui/Box.ts | 4 + .../src/v1/components/ui/Badge.tsx | 3 + .../src/v1/components/ui/ProjectLink.tsx | 3 + .../src/v1/services/projects.ts | 1 + .../cases/v1-unused-local/src/v1/lib/bad.ts | 4 + frontend/tsconfig.json | 7 +- frontend/vite.config.js | 2 + frontend/vitest.config.mjs | 14 +- 51 files changed, 725 insertions(+), 11 deletions(-) create mode 100755 frontend/scripts/check-v1.mjs create mode 100644 frontend/src/v1/README.md create mode 100644 frontend/src/v1/app/.gitkeep create mode 100644 frontend/src/v1/components/patterns/.gitkeep create mode 100644 frontend/src/v1/components/ui/.gitkeep create mode 100644 frontend/src/v1/hooks/.gitkeep create mode 100644 frontend/src/v1/index.ts create mode 100644 frontend/src/v1/lib/.gitkeep create mode 100644 frontend/src/v1/pages/.gitkeep create mode 100644 frontend/src/v1/plugins/manifest.ts create mode 100644 frontend/src/v1/services/.gitkeep create mode 100644 frontend/src/v1/shell/.gitkeep create mode 100644 frontend/src/v1/stores/.gitkeep create mode 100644 frontend/src/v1/styles/.gitkeep create mode 100644 frontend/src/v1/test/.gitkeep create mode 100644 frontend/src/v1/tsconfig.json create mode 100644 frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs create mode 100644 frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-entry/src/pages/Ok.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/lib/format.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/view/heading.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/test/render.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-ant-class-prefix/src/v1/components/ui/Button.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-base-rules-still-apply/src/v1/lib/bad.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-component-css-file/src/v1/components/ui/Badge.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-component-css-import/src/v1/components/ui/Badge.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-explicit-any/src/v1/lib/bad.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-implicit-any/src/v1/lib/bad.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-js-file/src/v1/lib/legacy.js create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-outside-taxonomy/src/v1/widgets/thing.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/settings/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-prop-types-assignment/src/v1/components/ui/Box.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-prop-types/src/v1/components/ui/Badge.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/services/projects.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-unused-local/src/v1/lib/bad.ts diff --git a/.gitignore b/.gitignore index cfd13e388f..c7a5148e30 100644 --- a/.gitignore +++ b/.gitignore @@ -41,8 +41,12 @@ eggs/ lib/ lib64/ # The Python `lib/` rule above also matches the frontend's shadcn/ui helper -# directory (`@/lib/utils` per components.json). Keep that tracked. +# directory (`@/lib/utils` per components.json), the v1 `lib/` folder and the +# v1 guardrail fixtures. Keep those tracked, or their files are silently left +# out of commits. !frontend/src/lib/ +!frontend/src/v1/lib/ +!frontend/test-fixtures/**/lib/ parts/ sdist/ var/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e279326d6f..fe06ede0a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,9 +22,9 @@ repos: - id: check-docstring-first - id: check-ast - id: check-json - # Both files are JSONC — their tooling (VS Code, tsc) allows comments, + # These files are JSONC — their tooling (VS Code, tsc) allows comments, # but this hook parses with strict `json.loads`, which does not. - exclude: "(\\.vscode/launch\\.json|frontend/tsconfig\\.json)$" + exclude: "(\\.vscode/launch\\.json|frontend/(src/v1/)?tsconfig\\.json)$" - id: check-executables-have-shebangs - id: check-shebang-scripts-are-executable - id: check-toml diff --git a/frontend/biome.json b/frontend/biome.json index eb98923c0c..c8ead404ef 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -129,5 +129,62 @@ "assist": { "enabled": true, "actions": { "source": { "organizeImports": "on" } } - } + }, + "overrides": [ + { + "includes": ["src/v1/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "paths": { + "prop-types": "v1 is typed with TypeScript; PropTypes are not used under src/v1.", + "antd": "antd is not used under src/v1." + }, + "patterns": [ + { + "group": ["../**", "@/**", "src/**"], + "message": "v1 imports nothing outside src/v1, and never with '../'. Use './' within a folder and '@v1/...' across folders." + }, + { + "group": ["@ant-design/**"], + "message": "antd is not used under src/v1." + }, + { + "group": ["**/*.css", "!@v1/styles/**"], + "message": "No component .css under src/v1; style with tokens and Tailwind. Global v1 styles live in @v1/styles/." + } + ] + } + } + }, + "suspicious": { + "noExplicitAny": "error" + } + } + } + }, + { + "includes": ["src/**", "!src/v1/**"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["@v1/**", "**/v1", "**/v1/**", "!@v1"], + "message": "v0 may import only the v1 public entry, as '@v1'." + } + ] + } + } + } + } + } + } + ] } diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json index 082b33982c..63ea90da3d 100644 --- a/frontend/jsconfig.json +++ b/frontend/jsconfig.json @@ -2,9 +2,10 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@v1": ["./src/v1/index.ts"] } }, "include": ["src/**/*"], - "exclude": ["node_modules", "build"] + "exclude": ["node_modules", "build", "src/v1"] } diff --git a/frontend/package.json b/frontend/package.json index b4ab1e1bd5..3d7f579cc9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -77,7 +77,10 @@ "check:fix": "biome check --write src/", "lint:all": "biome check --write src/", "lint:changed": "(git diff --name-only --diff-filter=ACMR; git ls-files --others --exclude-standard) | grep -E '\\.[jt]sx?$' | sed 's|^frontend/||' | sort -u | xargs -r biome check --write", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "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", + "test:v1": "vitest run --dir src/v1 --passWithNoTests && node --test test-fixtures/v1-boundary/boundary.fixtures.mjs" }, "browserslist": { "production": [ diff --git a/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs new file mode 100755 index 0000000000..6a27cb1783 --- /dev/null +++ b/frontend/scripts/check-v1.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node +/* + * Guard for the v1 frontend (src/v1/): the rules Biome cannot express. + * + * --files TypeScript only (a .js under v1 is skipped by tsc, not failed, + * because allowJs is off), style files only in styles/, and every + * file inside the v1 folder taxonomy. Run by `typecheck:v1`. + * --code The folder import contract (which v1 folder may import which), + * no PropTypes, no antd `ant-` class prefix. Run by `lint:v1`. + * + * With neither flag, both run. `--root ` points it at another tree + * (used by the fixtures in test-fixtures/v1-boundary/). + * + * The v0 <-> v1 boundary itself, `../` imports and banned packages are Biome + * rules (biome.json `overrides`), so they also run in CI and in editors. + * Biome overrides replace rule options rather than merge them, which is why + * the per-folder contract lives here and not as one override per folder. + * + * The `ant-` check exists only while antd markup survives in v0; drop it + * once the shadcn migration is complete. + */ +import fs from "node:fs"; +import path from "node:path"; + +const args = process.argv.slice(2); +const rootArg = args.indexOf("--root"); +const ROOT = path.resolve( + rootArg === -1 + ? path.join(import.meta.dirname, "..", "src", "v1") + : args[rootArg + 1], +); +const runFiles = args.includes("--files") || !args.includes("--code"); +const runCode = args.includes("--code") || !args.includes("--files"); + +const ALL = "*"; +/* + * Folder -> folders it may import from, besides itself. Mirrors the contract + * table in the P0-T01 spec and src/v1/README.md. `entry` is src/v1/index.ts. + * Pages may import everything except other pages. + */ +const CONTRACT = { + entry: ALL, + app: ALL, + plugins: ALL, + test: ALL, + pages: ALL, + "components/ui": ["lib", "styles"], + "components/patterns": [ + "components/ui", + "lib", + "services", + "stores", + "hooks", + ], + shell: [ + "components/ui", + "components/patterns", + "services", + "stores", + "hooks", + "lib", + ], + services: ["lib"], + stores: ["lib"], + hooks: ["lib"], + lib: [], + styles: ["lib"], +}; +const TOP_LEVEL = new Set([ + "app", + "shell", + "services", + "stores", + "hooks", + "lib", + "styles", + "plugins", + "test", +]); +const ROOT_FILES = new Set(["index.ts", "tsconfig.json", "README.md"]); + +const CODE_EXT = /\.(ts|tsx|mts|cts)$/; +const JS_EXT = /\.(js|jsx|mjs|cjs)$/; +const STYLE_EXT = /\.(css|scss|sass|less)$/; +const TEST_FILE = /\.(test|spec)\.tsx?$/; + +/* + * The v1 folder a path (relative to ROOT, `/`-separated) belongs to, e.g. + * "components/ui" or "pages/settings"; null when it is outside the taxonomy. + * `isDir` is for import targets like "@v1/lib", which name a folder. + */ +function folderOf(rel, isDir = false) { + const segs = rel.split("/").filter(Boolean); + const depth = isDir ? segs.length + 1 : segs.length; + if (segs.length === 0 || (depth === 1 && segs[0] === "index.ts")) { + return "entry"; + } + if (depth === 1) { + return null; + } + const [top, sub] = segs; + if (top === "components") { + return (sub === "ui" || sub === "patterns") && depth >= 3 + ? `components/${sub}` + : null; + } + if (top === "pages") { + return sub && depth >= 3 ? `pages/${sub}` : null; + } + return TOP_LEVEL.has(top) ? top : null; +} + +function contractKey(folder) { + return folder.startsWith("pages/") ? "pages" : folder; +} + +function mayImport(from, to, isTestFile) { + if (from === to) { + return true; + } + if (isTestFile && to === "test") { + return true; + } + if (from.startsWith("pages/") && to.startsWith("pages/")) { + return false; + } + const allowed = CONTRACT[contractKey(from)]; + return allowed === ALL || allowed.includes(to); +} + +/* Blanks comments while keeping line numbers, so matches can be reported. */ +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) + .replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1"); +} + +const IMPORT_RES = [ + /\b(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]+?\s+from\s+)?["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, +]; + +/* Resolves a specifier to a v1 folder, or undefined for packages and the + * forms Biome already rejects (`../`, `@/`). */ +function targetFolder(spec, fileRel) { + if (spec === "@v1" || /^@v1\/index(\.ts)?$/.test(spec)) { + return "entry"; + } + let rel; + if (spec.startsWith("@v1/")) { + rel = path.posix.normalize(spec.slice(4)); + } else if (spec.startsWith("./")) { + rel = path.posix.join(path.posix.dirname(fileRel), spec); + } else { + return undefined; + } + // No extension on the last segment -> it may name a folder ("@v1/lib"). + const isDir = !path.posix.extname(rel); + return folderOf(rel, isDir) ?? folderOf(rel) ?? null; +} + +function lineAt(text, index) { + return text.slice(0, index).split("\n").length; +} + +function walk(dir) { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((d) => d.isFile()) + .map((d) => + path + .relative(ROOT, path.join(d.parentPath, d.name)) + .replaceAll(path.sep, "/"), + ) + .sort(); +} + +const problems = []; +const report = (rel, line, msg) => + problems.push(`src/v1/${rel}${line ? `:${line}` : ""}: ${msg}`); + +if (!fs.existsSync(ROOT)) { + console.error(`check-v1: ${ROOT} does not exist`); + process.exit(2); +} + +for (const rel of walk(ROOT)) { + const name = path.posix.basename(rel); + const isRootFile = !rel.includes("/"); + const folder = folderOf(rel); + + if (runFiles) { + if (JS_EXT.test(name)) { + report(rel, 0, "v1 is TypeScript only; use .ts or .tsx."); + } + if (STYLE_EXT.test(name) && folder !== "styles") { + report(rel, 0, "No component style files; only styles/ holds CSS."); + } + if ( + isRootFile + ? !ROOT_FILES.has(name) + : folder === null && name !== ".gitkeep" + ) { + report(rel, 0, "Outside the v1 folder taxonomy (see src/v1/README.md)."); + } + } + + if (!runCode || !CODE_EXT.test(name) || folder === null) { + continue; + } + const text = stripComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); + const isTestFile = TEST_FILE.test(name); + + for (const re of IMPORT_RES) { + for (const m of text.matchAll(re)) { + const to = targetFolder(m[1], rel); + if (to === undefined) { + continue; + } + if (to === null) { + report( + rel, + lineAt(text, m.index), + `'${m[1]}' is outside the v1 folder taxonomy.`, + ); + } else if (!mayImport(folder, to, isTestFile)) { + report( + rel, + lineAt(text, m.index), + `${folder}/ may not import from ${to}/ ('${m[1]}'); see the folder contract in src/v1/README.md.`, + ); + } + } + } + for (const m of text.matchAll(/\.propTypes\b|\bpropTypes\s*[:=]/g)) { + report( + rel, + lineAt(text, m.index), + "No PropTypes under v1; type props with TypeScript.", + ); + } + for (const m of text.matchAll(/(? 0) { + console.error(problems.join("\n")); + console.error(`\ncheck-v1: ${problems.length} problem(s).`); + process.exit(1); +} +console.log("check-v1: ok"); diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md new file mode 100644 index 0000000000..0e375909e4 --- /dev/null +++ b/frontend/src/v1/README.md @@ -0,0 +1,49 @@ +# Frontend v1 + +The v1 frontend, built alongside v0 (everything else under `src/`) until v0 is +retired. The full guide is the Frontend Developer Guide for v1 (P0-T11); this +file holds only the rules that tooling enforces. + +## Rules + +- **TypeScript only.** `.ts` / `.tsx`, `strict`, unused locals and parameters + are errors, no explicit `any`. +- **Nothing outside `src/v1`.** No v0 component, store, hook, helper, CSS or + shim. Import with `./` within a folder and `@v1/...` across folders; `../` is + never used. +- **v0 imports only the public entry**, `@v1` (`src/v1/index.ts`). +- **No PropTypes, no component `.css`, no antd** (package, shim or `ant-` + class). Global v1 styles live in `styles/`. + +## Folders and what each may import + +Every folder may import from itself. Test files (`*.test.ts(x)`) may also +import `test/`. + +| Folder | Holds | May import from | +| --- | --- | --- | +| `index.ts` | Public entry, used by the version switch | everything | +| `app/` | Entry, providers, router, route registry | everything | +| `components/ui/` | Core components | `lib/`, `styles/` | +| `components/patterns/` | Composites built from core components | `components/ui/`, `lib/`, `services/`, `stores/`, `hooks/` | +| `shell/` | Top bar, rail, context bar, tab bar | `components/ui/`, `components/patterns/`, `services/`, `stores/`, `hooks/`, `lib/` | +| `pages//` | One folder per target page | everything except other pages | +| `services/` | Typed service modules; the only place a URL appears | `lib/` | +| `stores/` | Zustand stores | `lib/` | +| `hooks/` | Shared hooks | `lib/` | +| `lib/` | Pure helpers | — | +| `styles/` | Tokens and global v1 styles | `lib/` | +| `plugins/` | Cloud overlay target; empty in the public repository | everything | +| `test/` | Render helpers, fixtures, network mocks | everything | + +## Scripts + +| Script | Checks | +| --- | --- | +| `bun run typecheck:v1` | TypeScript-only files and folder taxonomy, then `tsc` with `src/v1/tsconfig.json` | +| `bun run lint:v1` | Biome on `src/v1/`, then the folder contract, PropTypes and `ant-` checks | +| `bun run test:v1` | v1 unit tests, then the guardrail fixtures in `test-fixtures/v1-boundary/` | + +The v0 ↔ v1 boundary and the banned imports are Biome rules in `biome.json`, +so `biome ci src/` (CI) and editors report them too. The rest live in +`scripts/check-v1.mjs`. diff --git a/frontend/src/v1/app/.gitkeep b/frontend/src/v1/app/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/components/patterns/.gitkeep b/frontend/src/v1/components/patterns/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/components/ui/.gitkeep b/frontend/src/v1/components/ui/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/hooks/.gitkeep b/frontend/src/v1/hooks/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/index.ts b/frontend/src/v1/index.ts new file mode 100644 index 0000000000..8a6566ee67 --- /dev/null +++ b/frontend/src/v1/index.ts @@ -0,0 +1,7 @@ +/* + * Public entry of the v1 frontend — the only v1 module v0 may import, as + * `@v1`. Lint rejects any other v0 → v1 import (see src/v1/README.md). + * + * Empty until the version switch (P0-T03) mounts v1 through it. + */ +export {}; diff --git a/frontend/src/v1/lib/.gitkeep b/frontend/src/v1/lib/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/pages/.gitkeep b/frontend/src/v1/pages/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/plugins/manifest.ts b/frontend/src/v1/plugins/manifest.ts new file mode 100644 index 0000000000..dcfd546ab4 --- /dev/null +++ b/frontend/src/v1/plugins/manifest.ts @@ -0,0 +1,6 @@ +/* + * Cloud overlay target. Empty in the public repository: the cloud build + * copies its v1 plugins into this folder. The manifest's shape and contents + * belong to P0-T02 and P0-T07. + */ +export const pluginManifest = [] as const; diff --git a/frontend/src/v1/services/.gitkeep b/frontend/src/v1/services/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/shell/.gitkeep b/frontend/src/v1/shell/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/stores/.gitkeep b/frontend/src/v1/stores/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/styles/.gitkeep b/frontend/src/v1/styles/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/test/.gitkeep b/frontend/src/v1/test/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/v1/tsconfig.json b/frontend/src/v1/tsconfig.json new file mode 100644 index 0000000000..f6b1c148ac --- /dev/null +++ b/frontend/src/v1/tsconfig.json @@ -0,0 +1,39 @@ +{ + /* + * TypeScript config for the v1 frontend, checked by `bun run typecheck:v1`. + * It lives here rather than beside tsconfig.json so editors pick it up for + * files under src/v1; tsconfig.json and jsconfig.json exclude this folder. + * + * `allowJs: false` makes tsc skip a .js file rather than fail on it, so the + * "TypeScript only" rule is enforced by scripts/check-v1.mjs, which + * typecheck:v1 runs first. + */ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + + "allowJs": false, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Only `@v1` — deliberately no `@/*`, so a v0 import fails typecheck as + * well as lint. Mirrored in vite.config.js and vitest.config.mjs. */ + "paths": { + "@v1": ["./index.ts"], + "@v1/*": ["./*"] + } + }, + "include": ["./**/*", "../../vite-env.d.ts"] +} diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs new file mode 100644 index 0000000000..a76001c182 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -0,0 +1,220 @@ +/* + * Proves the v1 guardrails fail when they should and pass when they should. + * Run by `bun run test:v1` (with `node --test`, not vitest, so the v0 suite + * never collects it). + * + * Each case builds a throwaway copy of the frontend: biome.json, + * package.json, scripts/, the real src/v1 scaffold and a node_modules + * symlink. It then overlays the files under cases// and runs the real + * package.json scripts (or a raw command) inside it. The known-bad files + * live here, outside src/, so they never reach the real lint or typecheck. + */ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +const FRONTEND = path.resolve(import.meta.dirname, "..", ".."); +const CASES_DIR = path.join(import.meta.dirname, "cases"); +const { scripts } = JSON.parse( + fs.readFileSync(path.join(FRONTEND, "package.json"), "utf8"), +); + +/* + * `run` holds package.json script names, or raw commands for the v0 side, + * which has no v1 script. `fails` is a substring the output must contain; + * cases without it must pass. + */ +const CASES = [ + // The empty scaffold passes everything. + { name: null, run: ["typecheck:v1", "lint:v1"] }, + + // Import boundary, v1 -> v0 (Biome). + { + name: "v1-imports-v0-alias", + run: ["lint:v1"], + fails: "v1 imports nothing outside src/v1", + }, + { + name: "v1-imports-v0-relative", + run: ["lint:v1"], + fails: "v1 imports nothing outside src/v1", + }, + + // Import boundary, v0 -> v1 (Biome). + { + name: "v0-imports-v1-internal", + run: ["biome lint src/"], + fails: "v0 may import only the v1 public entry", + }, + { + name: "v0-imports-v1-relative", + run: ["biome lint src/"], + fails: "v0 may import only the v1 public entry", + }, + { name: "v0-imports-v1-entry", run: ["biome lint src/"] }, + + // Banned under v1. + { name: "v1-prop-types", run: ["lint:v1"], fails: "PropTypes are not used" }, + { + name: "v1-prop-types-assignment", + run: ["lint:v1"], + fails: "No PropTypes under v1", + }, + { + name: "v1-component-css-import", + run: ["lint:v1"], + fails: "No component .css under src/v1", + }, + { + name: "v1-component-css-file", + run: ["typecheck:v1"], + fails: "No component style files", + }, + { + name: "v1-ant-class-prefix", + run: ["lint:v1"], + fails: "No antd 'ant-' class names", + }, + { name: "v1-explicit-any", run: ["lint:v1"], fails: "noExplicitAny" }, + // The v1 override must add to the shared rules, not replace them. + { + name: "v1-base-rules-still-apply", + run: ["lint:v1"], + fails: "noDoubleEquals", + }, + + // TypeScript only, strict. + { name: "v1-js-file", run: ["typecheck:v1"], fails: "TypeScript only" }, + { + name: "v1-implicit-any", + run: ["typecheck:v1"], + fails: "implicitly has an 'any' type", + }, + { + name: "v1-unused-local", + run: ["typecheck:v1"], + fails: "is declared but its value is never read", + }, + + // Folder taxonomy and contract. + { + name: "v1-outside-taxonomy", + run: ["typecheck:v1"], + fails: "Outside the v1 folder taxonomy", + }, + { + name: "v1-ui-imports-services", + run: ["lint:v1"], + fails: "components/ui/ may not import from services/", + }, + { + name: "v1-page-imports-other-page", + run: ["lint:v1"], + fails: "pages/projects/ may not import from pages/settings/", + }, + { name: "v1-allowed-imports", run: ["typecheck:v1", "lint:v1"] }, +]; + +function makeTree(caseName) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "v1-boundary-")); + for (const file of ["biome.json", "package.json", "vite-env.d.ts"]) { + fs.copyFileSync(path.join(FRONTEND, file), path.join(dir, file)); + } + // Copied, not linked: check-v1.mjs finds src/v1 relative to itself. + fs.cpSync(path.join(FRONTEND, "scripts"), path.join(dir, "scripts"), { + recursive: true, + }); + fs.cpSync(path.join(FRONTEND, "src", "v1"), path.join(dir, "src", "v1"), { + recursive: true, + }); + fs.symlinkSync( + path.join(FRONTEND, "node_modules"), + path.join(dir, "node_modules"), + "dir", + ); + if (caseName) { + fs.cpSync(path.join(CASES_DIR, caseName), dir, { recursive: true }); + } + // biome.json enables VCS integration with `useIgnoreFile`, which needs a + // repository root holding an ignore file. + execFileSync("git", ["init", "-q"], { cwd: dir }); + fs.writeFileSync(path.join(dir, ".gitignore"), "node_modules\n"); + return dir; +} + +function removeTree(dir) { + // Unlink first so the recursive delete can never walk into node_modules. + fs.unlinkSync(path.join(dir, "node_modules")); + fs.rmSync(dir, { recursive: true, force: true }); +} + +function runAll(dir, run) { + let output = ""; + for (const step of run) { + const command = scripts[step] ?? step; + const result = spawnSync("sh", ["-c", command], { + cwd: dir, + encoding: "utf8", + env: { + ...process.env, + NO_COLOR: "1", + PATH: `${path.join(dir, "node_modules", ".bin")}${path.delimiter}${process.env.PATH}`, + }, + }); + output += `$ ${command}\n${result.stdout}${result.stderr}`; + if (result.status !== 0) { + return { ok: false, output }; + } + } + return { ok: true, output }; +} + +/* + * The repository's Python `lib/` ignore rule also matches src/v1/lib/ and the + * fixtures' lib/ folders; .gitignore re-includes them. `--no-index` checks the + * rules even for files already tracked. + */ +test("no v1 or fixture file is git-ignored", () => { + const files = ["src/v1", "test-fixtures"].flatMap((top) => + fs + .readdirSync(path.join(FRONTEND, top), { + recursive: true, + withFileTypes: true, + }) + .filter((d) => d.isFile()) + .map((d) => path.relative(FRONTEND, path.join(d.parentPath, d.name))), + ); + const result = spawnSync("git", ["check-ignore", "--no-index", "--stdin"], { + cwd: FRONTEND, + encoding: "utf8", + input: files.join("\n"), + }); + // Exit 1 means "nothing matched"; 0 means some file is ignored. + assert.equal(result.status, 1, `git-ignored:\n${result.stdout}`); +}); + +test("every case folder is listed", () => { + const listed = new Set(CASES.map((c) => c.name).filter(Boolean)); + assert.deepEqual(fs.readdirSync(CASES_DIR).sort(), [...listed].sort()); +}); + +for (const { name, run, fails } of CASES) { + const label = `${name ?? "empty scaffold"}: ${fails ? "fails" : "passes"} ${run.join(", ")}`; + test(label, () => { + const dir = makeTree(name); + try { + const { ok, output } = runAll(dir, run); + if (fails) { + assert.equal(ok, false, `expected a failure, got:\n${output}`); + assert.ok(output.includes(fails), `expected "${fails}" in:\n${output}`); + } else { + assert.equal(ok, true, output); + } + } finally { + removeTree(dir); + } + }); +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-entry/src/pages/Ok.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-entry/src/pages/Ok.jsx new file mode 100644 index 0000000000..8e96182e95 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-entry/src/pages/Ok.jsx @@ -0,0 +1 @@ +import "@v1"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx new file mode 100644 index 0000000000..d822a2d078 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx @@ -0,0 +1,3 @@ +import { pluginManifest } from "@v1/plugins/manifest"; + +export const manifest = pluginManifest; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx new file mode 100644 index 0000000000..d462e4d68a --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx @@ -0,0 +1,3 @@ +import { pluginManifest } from "../v1/plugins/manifest"; + +export const manifest = pluginManifest; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts new file mode 100644 index 0000000000..1cb7e9a430 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts @@ -0,0 +1,4 @@ +import { renderLabel } from "@v1/test/render"; +import { label } from "./Label"; + +export const rendered = renderLabel(label("ok")); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.ts new file mode 100644 index 0000000000..ef8a7487be --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.ts @@ -0,0 +1,3 @@ +import { upper } from "@v1/lib/format"; + +export const label = (text: string): string => upper(text); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/lib/format.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/lib/format.ts new file mode 100644 index 0000000000..ee479a8063 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/lib/format.ts @@ -0,0 +1 @@ +export const upper = (value: string): string => value.toUpperCase(); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.ts new file mode 100644 index 0000000000..b902577b52 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.ts @@ -0,0 +1 @@ +export const title = "Projects"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/view/heading.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/view/heading.ts new file mode 100644 index 0000000000..671f792f33 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/view/heading.ts @@ -0,0 +1,4 @@ +import { label } from "@v1/components/ui/Label"; +import { title } from "@v1/pages/projects/title"; + +export const heading = label(title); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/test/render.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/test/render.ts new file mode 100644 index 0000000000..858e72d057 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/test/render.ts @@ -0,0 +1 @@ +export const renderLabel = (text: string): string => text; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-ant-class-prefix/src/v1/components/ui/Button.ts b/frontend/test-fixtures/v1-boundary/cases/v1-ant-class-prefix/src/v1/components/ui/Button.ts new file mode 100644 index 0000000000..a87d0cde10 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-ant-class-prefix/src/v1/components/ui/Button.ts @@ -0,0 +1 @@ +export const buttonClass = "ant-btn primary"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-base-rules-still-apply/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-base-rules-still-apply/src/v1/lib/bad.ts new file mode 100644 index 0000000000..32f9a0a998 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-base-rules-still-apply/src/v1/lib/bad.ts @@ -0,0 +1 @@ +export const same = (a: number, b: number): boolean => a == b; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-component-css-file/src/v1/components/ui/Badge.css b/frontend/test-fixtures/v1-boundary/cases/v1-component-css-file/src/v1/components/ui/Badge.css new file mode 100644 index 0000000000..63bd92461e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-component-css-file/src/v1/components/ui/Badge.css @@ -0,0 +1,3 @@ +.badge { + color: red; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-component-css-import/src/v1/components/ui/Badge.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-component-css-import/src/v1/components/ui/Badge.tsx new file mode 100644 index 0000000000..be8a92895c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-component-css-import/src/v1/components/ui/Badge.tsx @@ -0,0 +1,3 @@ +import "./Badge.css"; + +export const badge = "badge"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-explicit-any/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-explicit-any/src/v1/lib/bad.ts new file mode 100644 index 0000000000..6724498208 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-explicit-any/src/v1/lib/bad.ts @@ -0,0 +1 @@ +export const value: any = 1; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-implicit-any/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-implicit-any/src/v1/lib/bad.ts new file mode 100644 index 0000000000..cf9f27088d --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-implicit-any/src/v1/lib/bad.ts @@ -0,0 +1,3 @@ +export function identity(value) { + return value; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts new file mode 100644 index 0000000000..b6e5854b2b --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts @@ -0,0 +1,3 @@ +import { getBaseUrl } from "@/helpers/GetStaticData"; + +export const base = getBaseUrl; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts new file mode 100644 index 0000000000..d2c250ae68 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts @@ -0,0 +1,3 @@ +import { getBaseUrl } from "../../helpers/GetStaticData"; + +export const base = getBaseUrl; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-js-file/src/v1/lib/legacy.js b/frontend/test-fixtures/v1-boundary/cases/v1-js-file/src/v1/lib/legacy.js new file mode 100644 index 0000000000..7e15a4a640 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-js-file/src/v1/lib/legacy.js @@ -0,0 +1 @@ +export const legacy = 1; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-outside-taxonomy/src/v1/widgets/thing.ts b/frontend/test-fixtures/v1-boundary/cases/v1-outside-taxonomy/src/v1/widgets/thing.ts new file mode 100644 index 0000000000..61483b3477 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-outside-taxonomy/src/v1/widgets/thing.ts @@ -0,0 +1 @@ +export const thing = 1; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts new file mode 100644 index 0000000000..1a075e1cc3 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts @@ -0,0 +1,3 @@ +import { settingsTitle } from "@v1/pages/settings"; + +export const title = settingsTitle; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/settings/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/settings/index.ts new file mode 100644 index 0000000000..317694fcb8 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/settings/index.ts @@ -0,0 +1 @@ +export const settingsTitle = "Settings"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-prop-types-assignment/src/v1/components/ui/Box.ts b/frontend/test-fixtures/v1-boundary/cases/v1-prop-types-assignment/src/v1/components/ui/Box.ts new file mode 100644 index 0000000000..e6e9ef769a --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-prop-types-assignment/src/v1/components/ui/Box.ts @@ -0,0 +1,4 @@ +export function Box(): null { + return null; +} +Box.propTypes = {}; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-prop-types/src/v1/components/ui/Badge.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-prop-types/src/v1/components/ui/Badge.tsx new file mode 100644 index 0000000000..64c5471dfb --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-prop-types/src/v1/components/ui/Badge.tsx @@ -0,0 +1,3 @@ +import PropTypes from "prop-types"; + +export const badgeShape = PropTypes.string; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx new file mode 100644 index 0000000000..3863836b20 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx @@ -0,0 +1,3 @@ +import { projectsUrl } from "@v1/services/projects"; + +export const href = projectsUrl; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/services/projects.ts b/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/services/projects.ts new file mode 100644 index 0000000000..934524c2f4 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/services/projects.ts @@ -0,0 +1 @@ +export const projectsUrl = "/api/v1/projects"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-unused-local/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-unused-local/src/v1/lib/bad.ts new file mode 100644 index 0000000000..ac320e04db --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-unused-local/src/v1/lib/bad.ts @@ -0,0 +1,4 @@ +export function double(value: number): number { + const unused = 1; + return value * 2; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index f7e7014f49..779791063e 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -40,9 +40,12 @@ * `baseUrl` was removed in TypeScript 6, so the paths are relative to * this file's directory. */ "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + /* v0 may import only the v1 public entry, so only that is mapped. */ + "@v1": ["./src/v1/index.ts"] } }, "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"], - "exclude": ["node_modules", "build", "dist"] + /* src/v1 is checked by src/v1/tsconfig.json under stricter settings. */ + "exclude": ["node_modules", "build", "dist", "src/v1"] } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index af124153fc..e9882c5d8f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -184,6 +184,8 @@ export default defineConfig(({ mode }) => { resolve: { alias: { "@": path.resolve(import.meta.dirname, "./src"), + // v1 frontend; mirrored in src/v1/tsconfig.json and vitest.config.mjs. + "@v1": path.resolve(import.meta.dirname, "./src/v1"), }, }, diff --git a/frontend/vitest.config.mjs b/frontend/vitest.config.mjs index 128c7f042f..915c23d11d 100644 --- a/frontend/vitest.config.mjs +++ b/frontend/vitest.config.mjs @@ -94,6 +94,8 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(import.meta.dirname, "./src"), + // Mirror of the `@v1` alias in vite.config.js. + "@v1": path.resolve(import.meta.dirname, "./src/v1"), }, }, test: { @@ -107,8 +109,16 @@ export default defineConfig({ * The Playwright suite (the rig's `ui` group) lives in `tests/e2e/ui`, out * of this tree entirely, so vitest's default glob no longer reaches it. * `build/` is listed because a production build lands there and its - * bundled chunks would otherwise be walked. + * bundled chunks would otherwise be walked. `test-fixtures/` holds + * deliberately broken trees (some with `*.test.ts` files) that + * test-fixtures/v1-boundary/boundary.fixtures.mjs lints and type-checks; + * they are inputs, not tests. */ - exclude: ["**/node_modules/**", "**/dist/**", "**/build/**"], + exclude: [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "test-fixtures/**", + ], }, }); From 5c55a7c12aca0e21c69094b2d39456e702f8f78f Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Mon, 26 Oct 2026 18:04:01 +0530 Subject: [PATCH 2/6] fix(frontend): close v1 guardrail bypasses found in self-review (UN-4270) check-v1.mjs: - Scan comments with a string/template-aware tokenizer; a `/*` or `//` inside a string or line comment no longer hides the imports after it. - Read template-literal specifiers, import.meta.glob and `import(..., opts)`. - Flag specifiers that leave src/v1 (`/`, `..`, `@/`, `src/`) itself, not only via Biome, which skips template literals. - Check `@import`/`url()` and `ant-` in styles/ CSS; report symlinks; catch built `ant-` names; treat .test/.spec .mts/.cts as test files; ignore .DS_Store; drop the unused --root option. biome.json: also ban `..`, `./../**`, `/**`, `antd/**`, `prop-types/**`. tsconfig: skipLibCheck off so v1's own .d.ts files are checked. .gitignore: re-include lib/ at any depth under src/v1. Fixtures: a generated matrix over every folder-contract pair, every import form, every outside spelling, a symlink case, and cases for each new rule; tsc cases assert error codes; output matching excludes the command text and spawn errors fail loudly. Comment and README corrections. --- .gitignore | 2 +- frontend/biome.json | 15 +- frontend/scripts/check-v1.mjs | 178 ++++++++--- frontend/src/v1/README.md | 23 +- frontend/src/v1/index.ts | 2 +- frontend/src/v1/plugins/manifest.ts | 2 +- frontend/src/v1/tsconfig.json | 9 +- .../v1-boundary/boundary.fixtures.mjs | 294 ++++++++++++++++-- .../src/v1/components/ui/Tag.tsx | 7 + .../v1-allowed-imports/src/v1/styles/index.ts | 3 + .../src/v1/styles/tokens.css | 3 + .../src/v1/components/ui/Tag.ts | 1 + .../src/v1/components/ui/Button.ts | 3 + .../src/v1/components/ui/Button.ts | 3 + .../src/v1/lib/logo.ts | 3 + .../src/v1/components/ui/Helper.ts | 3 + .../src/v1/test/render.ts | 1 + .../src/v1/components/ui/Badge.ts | 3 + .../cases/v1-root-file/src/v1/main.ts | 1 + .../cases/v1-strict-null/src/v1/lib/length.ts | 1 + .../src/v1/styles/global.css | 3 + .../src/v1/styles/global.css | 1 + .../src/v1/components/ui/Link.test.ts | 3 + .../src/v1/services/projects.ts | 1 + .../v1-unused-parameter/src/v1/lib/one.ts | 1 + frontend/vite.config.js | 3 +- frontend/vitest.config.mjs | 2 +- 27 files changed, 494 insertions(+), 77 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-ant-built-class/src/v1/components/ui/Tag.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-antd-subpath/src/v1/components/ui/Button.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-imports-root-absolute/src/v1/lib/logo.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/components/ui/Helper.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/test/render.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-prop-types-subpath/src/v1/components/ui/Badge.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-root-file/src/v1/main.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-strict-null/src/v1/lib/length.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-ant-class/src/v1/styles/global.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-styles-import-outside/src/v1/styles/global.css create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/components/ui/Link.test.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/services/projects.ts create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-unused-parameter/src/v1/lib/one.ts diff --git a/.gitignore b/.gitignore index c7a5148e30..4817bafa05 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,7 @@ lib64/ # v1 guardrail fixtures. Keep those tracked, or their files are silently left # out of commits. !frontend/src/lib/ -!frontend/src/v1/lib/ +!frontend/src/v1/**/lib/ !frontend/test-fixtures/**/lib/ parts/ sdist/ diff --git a/frontend/biome.json b/frontend/biome.json index c8ead404ef..6799918af6 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -145,13 +145,24 @@ }, "patterns": [ { - "group": ["../**", "@/**", "src/**"], + "group": [ + "../**", + "..", + "./../**", + "/**", + "@/**", + "src/**" + ], "message": "v1 imports nothing outside src/v1, and never with '../'. Use './' within a folder and '@v1/...' across folders." }, { - "group": ["@ant-design/**"], + "group": ["@ant-design/**", "antd/**"], "message": "antd is not used under src/v1." }, + { + "group": ["prop-types/**"], + "message": "v1 is typed with TypeScript; PropTypes are not used under src/v1." + }, { "group": ["**/*.css", "!@v1/styles/**"], "message": "No component .css under src/v1; style with tokens and Tailwind. Global v1 styles live in @v1/styles/." diff --git a/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs index 6a27cb1783..e5d6612ae9 100755 --- a/frontend/scripts/check-v1.mjs +++ b/frontend/scripts/check-v1.mjs @@ -5,16 +5,19 @@ * --files TypeScript only (a .js under v1 is skipped by tsc, not failed, * because allowJs is off), style files only in styles/, and every * file inside the v1 folder taxonomy. Run by `typecheck:v1`. - * --code The folder import contract (which v1 folder may import which), - * no PropTypes, no antd `ant-` class prefix. Run by `lint:v1`. + * --code Imports: nothing outside src/v1 and the folder import contract + * (which v1 folder may import which). No PropTypes, no antd `ant-` + * class names. Run by `lint:v1`. * - * With neither flag, both run. `--root ` points it at another tree - * (used by the fixtures in test-fixtures/v1-boundary/). + * With neither flag, both run. It always checks the src/v1 beside this + * script; the fixtures in test-fixtures/v1-boundary/ copy it into each + * throwaway tree for that reason. * - * The v0 <-> v1 boundary itself, `../` imports and banned packages are Biome - * rules (biome.json `overrides`), so they also run in CI and in editors. - * Biome overrides replace rule options rather than merge them, which is why - * the per-folder contract lives here and not as one override per folder. + * The v0 <-> v1 boundary is also a Biome rule (biome.json `overrides`), so CI + * and editors report it. The script repeats the v1 -> outside half because it + * also reads template-literal imports, which Biome does not. Biome overrides + * replace rule options rather than merge them, which is why the per-folder + * contract lives here and not as one override per folder. * * The `ant-` check exists only while antd markup survives in v0; drop it * once the shadcn migration is complete. @@ -22,21 +25,16 @@ import fs from "node:fs"; import path from "node:path"; +const ROOT = path.join(import.meta.dirname, "..", "src", "v1"); const args = process.argv.slice(2); -const rootArg = args.indexOf("--root"); -const ROOT = path.resolve( - rootArg === -1 - ? path.join(import.meta.dirname, "..", "src", "v1") - : args[rootArg + 1], -); const runFiles = args.includes("--files") || !args.includes("--code"); const runCode = args.includes("--code") || !args.includes("--files"); const ALL = "*"; /* - * Folder -> folders it may import from, besides itself. Mirrors the contract - * table in the P0-T01 spec and src/v1/README.md. `entry` is src/v1/index.ts. - * Pages may import everything except other pages. + * Folder -> folders it may import from, besides itself. `entry` is + * src/v1/index.ts. Pages may import everything except other pages. + * Mirrors the table in src/v1/README.md; change both together. */ const CONTRACT = { entry: ALL, @@ -82,10 +80,12 @@ const ROOT_FILES = new Set(["index.ts", "tsconfig.json", "README.md"]); const CODE_EXT = /\.(ts|tsx|mts|cts)$/; const JS_EXT = /\.(js|jsx|mjs|cjs)$/; const STYLE_EXT = /\.(css|scss|sass|less)$/; -const TEST_FILE = /\.(test|spec)\.tsx?$/; +const TEST_FILE = /\.(test|spec)\.(ts|tsx|mts|cts)$/; +// Specifiers that leave src/v1: root-absolute, parent, the v0 alias, src/. +const OUTSIDE = /^(\/|\.\.(\/|$)|@\/|src\/)/; /* - * The v1 folder a path (relative to ROOT, `/`-separated) belongs to, e.g. + * The v1 folder a path (relative to src/v1, `/`-separated) belongs to, e.g. * "components/ui" or "pages/settings"; null when it is outside the taxonomy. * `isDir` is for import targets like "@v1/lib", which name a folder. */ @@ -128,22 +128,93 @@ function mayImport(from, to, isTestFile) { return allowed === ALL || allowed.includes(to); } -/* Blanks comments while keeping line numbers, so matches can be reported. */ -function stripComments(src) { - return src - .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) - .replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1"); +/* + * Blanks comments, keeping line numbers so matches can be reported. Tracks + * strings and template literals (including `${}` expressions), so a `/*` or + * `//` inside a string does not swallow the code after it. Regex literals are + * not tracked; a quote inside one is read as a string to the end of the line + * at most, so it can hide that line only. + */ +function blankComments(src) { + let out = ""; + let i = 0; + // "`" = inside a template literal; "{" = inside a `${}` or a brace in one. + const stack = []; + while (i < src.length) { + const c = src[i]; + const next = src[i + 1]; + if (stack.at(-1) === "`") { + if (c === "\\") { + out += src.slice(i, i + 2); + i += 2; + } else if (c === "`") { + stack.pop(); + out += c; + i += 1; + } else if (c === "$" && next === "{") { + stack.push("{"); + out += "${"; + i += 2; + } else { + out += c; + i += 1; + } + continue; + } + if (c === "/" && (next === "/" || next === "*")) { + const close = + next === "/" ? src.indexOf("\n", i) : src.indexOf("*/", i + 2); + const end = close === -1 ? src.length : next === "/" ? close : close + 2; + out += src.slice(i, end).replace(/[^\n]/g, " "); + i = end; + } else if (c === '"' || c === "'") { + let j = i + 1; + while (j < src.length && src[j] !== c && src[j] !== "\n") { + j += src[j] === "\\" ? 2 : 1; + } + out += src.slice(i, j + 1); + i = j + 1; + } else { + if (c === "`") { + stack.push("`"); + } else if (c === "{" && stack.length > 0) { + stack.push("{"); + } else if (c === "}" && stack.length > 0) { + stack.pop(); + } + out += c; + i += 1; + } + } + return out; } +// A specifier: a quoted or backtick string with no interpolation. +const SPEC = "[\"'`]([^\"'`$]+)[\"'`]"; const IMPORT_RES = [ - /\b(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]+?\s+from\s+)?["']([^"']+)["']/g, - /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, - /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, + new RegExp( + `\\b(?:import|export)\\s+(?:type\\s+)?(?:[\\w$*{}\\s,]+?\\s+from\\s+)?${SPEC}`, + "g", + ), + new RegExp(`\\bimport\\s*\\(\\s*${SPEC}\\s*[,)]`, "g"), + new RegExp(`\\brequire\\s*\\(\\s*${SPEC}\\s*\\)`, "g"), + // Vite globs; the first pattern of an array is checked. + new RegExp( + `\\bimport\\.meta\\.glob\\s*(?:<[^>]*>)?\\(\\s*\\[?\\s*${SPEC}`, + "g", + ), ]; +// CSS `@import` and `url()` targets, for the files in styles/. +const CSS_IMPORT_RE = /(?:@import\s+(?:url\(\s*)?|url\(\s*)["']?([^"')\s;]+)/g; -/* Resolves a specifier to a v1 folder, or undefined for packages and the - * forms Biome already rejects (`../`, `@/`). */ +/* + * Classifies a specifier: "outside" when it leaves src/v1; the v1 folder it + * names; null for a v1 path outside the taxonomy; undefined for a package. + */ function targetFolder(spec, fileRel) { + if (OUTSIDE.test(spec)) { + return "outside"; + } if (spec === "@v1" || /^@v1\/index(\.ts)?$/.test(spec)) { return "entry"; } @@ -155,6 +226,9 @@ function targetFolder(spec, fileRel) { } else { return undefined; } + if (rel.startsWith("..")) { + return "outside"; + } // No extension on the last segment -> it may name a folder ("@v1/lib"). const isDir = !path.posix.extname(rel); return folderOf(rel, isDir) ?? folderOf(rel) ?? null; @@ -164,10 +238,15 @@ function lineAt(text, index) { return text.slice(0, index).split("\n").length; } +// Finder litter; never committed (.gitignore), so not worth reporting. +const IGNORED = new Set([".DS_Store"]); + +/* Every file under dir, plus symlinks, which are reported rather than + * followed: a linked file would bring outside code in unchecked. */ function walk(dir) { return fs .readdirSync(dir, { recursive: true, withFileTypes: true }) - .filter((d) => d.isFile()) + .filter((d) => (d.isFile() || d.isSymbolicLink()) && !IGNORED.has(d.name)) .map((d) => path .relative(ROOT, path.join(d.parentPath, d.name)) @@ -190,6 +269,13 @@ for (const rel of walk(ROOT)) { const isRootFile = !rel.includes("/"); const folder = folderOf(rel); + if (fs.lstatSync(path.join(ROOT, rel)).isSymbolicLink()) { + if (runFiles) { + report(rel, 0, "No symlinks under v1; they bypass these checks."); + } + continue; + } + if (runFiles) { if (JS_EXT.test(name)) { report(rel, 0, "v1 is TypeScript only; use .ts or .tsx."); @@ -206,28 +292,47 @@ for (const rel of walk(ROOT)) { } } + if (runCode && STYLE_EXT.test(name) && folder === "styles") { + const css = blankComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); + for (const m of css.matchAll(CSS_IMPORT_RE)) { + if (targetFolder(m[1], rel) === "outside") { + report( + rel, + lineAt(css, m.index), + `'${m[1]}' is outside src/v1; v1 styles import nothing outside it.`, + ); + } + } + for (const m of css.matchAll(/(?/ and runs the real - * package.json scripts (or a raw command) inside it. The known-bad files - * live here, outside src/, so they never reach the real lint or typecheck. + * Each case builds a minimal throwaway copy of the frontend (biome.json, + * package.json, vite-env.d.ts, scripts/, the real src/v1 scaffold, a + * node_modules symlink and a git root), overlays the files under + * cases// and runs the real package.json scripts, or a raw command, + * inside it. The known-bad files live here, outside src/, so CI lint, the + * pre-commit hook and typecheck never see them. */ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; @@ -24,8 +25,8 @@ const { scripts } = JSON.parse( /* * `run` holds package.json script names, or raw commands for the v0 side, - * which has no v1 script. `fails` is a substring the output must contain; - * cases without it must pass. + * which has no v1 script. `fails` is a substring the tool output must + * contain; cases without it must pass. */ const CASES = [ // The empty scaffold passes everything. @@ -42,6 +43,11 @@ const CASES = [ run: ["lint:v1"], fails: "v1 imports nothing outside src/v1", }, + { + name: "v1-imports-root-absolute", + run: ["lint:v1"], + fails: "v1 imports nothing outside src/v1", + }, // Import boundary, v0 -> v1 (Biome). { @@ -63,6 +69,31 @@ const CASES = [ run: ["lint:v1"], fails: "No PropTypes under v1", }, + { + name: "v1-antd-import", + run: ["lint:v1"], + fails: "antd is not used under src/v1", + }, + { + name: "v1-antd-subpath", + run: ["lint:v1"], + fails: "antd is not used under src/v1", + }, + { + name: "v1-prop-types-subpath", + run: ["lint:v1"], + fails: "PropTypes are not used", + }, + { + name: "v1-styles-import-outside", + run: ["lint:v1"], + fails: "v1 styles import nothing outside it", + }, + { + name: "v1-styles-ant-class", + run: ["lint:v1"], + fails: "No antd 'ant-' class names", + }, { name: "v1-component-css-import", run: ["lint:v1"], @@ -78,6 +109,11 @@ const CASES = [ run: ["lint:v1"], fails: "No antd 'ant-' class names", }, + { + name: "v1-ant-built-class", + run: ["lint:v1"], + fails: "No antd 'ant-' class names", + }, { name: "v1-explicit-any", run: ["lint:v1"], fails: "noExplicitAny" }, // The v1 override must add to the shared rules, not replace them. { @@ -91,20 +127,35 @@ const CASES = [ { name: "v1-implicit-any", run: ["typecheck:v1"], - fails: "implicitly has an 'any' type", + fails: "TS7006", + }, + { + name: "v1-strict-null", + run: ["typecheck:v1"], + fails: "TS18048", }, { name: "v1-unused-local", run: ["typecheck:v1"], - fails: "is declared but its value is never read", + fails: "TS6133", + }, + { + name: "v1-unused-parameter", + run: ["typecheck:v1"], + fails: "TS6133", }, - // Folder taxonomy and contract. + // Folder taxonomy and contract (the full contract is the matrix below). { name: "v1-outside-taxonomy", run: ["typecheck:v1"], fails: "Outside the v1 folder taxonomy", }, + { + name: "v1-root-file", + run: ["typecheck:v1"], + fails: "Outside the v1 folder taxonomy", + }, { name: "v1-ui-imports-services", run: ["lint:v1"], @@ -115,6 +166,17 @@ const CASES = [ run: ["lint:v1"], fails: "pages/projects/ may not import from pages/settings/", }, + // Only test files may use test/, and being a test file allows nothing else. + { + name: "v1-non-test-imports-test", + run: ["lint:v1"], + fails: "components/ui/ may not import from test/", + }, + { + name: "v1-test-file-imports-services", + run: ["lint:v1"], + fails: "components/ui/ may not import from services/", + }, { name: "v1-allowed-imports", run: ["typecheck:v1", "lint:v1"] }, ]; @@ -151,8 +213,20 @@ function removeTree(dir) { fs.rmSync(dir, { recursive: true, force: true }); } +function writeFile(dir, rel, content) { + const file = path.join(dir, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +/* + * Runs the steps in order, stopping at the first failure. `output` holds only + * what the tools printed, so an expected message can never match the command + * text. A command that could not start at all is an error, not a "failure". + */ function runAll(dir, run) { let output = ""; + let log = ""; for (const step of run) { const command = scripts[step] ?? step; const result = spawnSync("sh", ["-c", command], { @@ -164,12 +238,34 @@ function runAll(dir, run) { PATH: `${path.join(dir, "node_modules", ".bin")}${path.delimiter}${process.env.PATH}`, }, }); - output += `$ ${command}\n${result.stdout}${result.stderr}`; + if (result.error || result.status === null) { + throw new Error( + `could not run "${command}": ${result.error ?? result.signal}`, + ); + } + // 127: sh could not find the command, e.g. a tool missing from PATH. + assert.notEqual(result.status, 127, `command not found: ${command}`); + output += result.stdout + result.stderr; + log += `$ ${command}\n${result.stdout}${result.stderr}`; if (result.status !== 0) { - return { ok: false, output }; + return { ok: false, output, log }; + } + } + return { ok: true, output, log }; +} + +/* Runs `check-v1.mjs --code` over generated files; returns the reported lines. */ +function checkCode(files) { + const dir = makeTree(null); + try { + for (const [rel, content] of Object.entries(files)) { + writeFile(dir, rel, content); } + const { output } = runAll(dir, ["node scripts/check-v1.mjs --code"]); + return output.split("\n").filter((l) => l.startsWith("src/v1/")); + } finally { + removeTree(dir); } - return { ok: true, output }; } /* @@ -184,9 +280,11 @@ test("no v1 or fixture file is git-ignored", () => { recursive: true, withFileTypes: true, }) - .filter((d) => d.isFile()) + .filter((d) => d.isFile() && d.name !== ".DS_Store") .map((d) => path.relative(FRONTEND, path.join(d.parentPath, d.name))), ); + // A lib/ folder anywhere under v1, not only the top-level one. + files.push("src/v1/pages/projects/lib/x.ts", "src/v1/lib/sub/lib/x.ts"); const result = spawnSync("git", ["check-ignore", "--no-index", "--stdin"], { cwd: FRONTEND, encoding: "utf8", @@ -206,15 +304,171 @@ for (const { name, run, fails } of CASES) { test(label, () => { const dir = makeTree(name); try { - const { ok, output } = runAll(dir, run); + const { ok, output, log } = runAll(dir, run); if (fails) { - assert.equal(ok, false, `expected a failure, got:\n${output}`); - assert.ok(output.includes(fails), `expected "${fails}" in:\n${output}`); + assert.equal(ok, false, `expected a failure, got:\n${log}`); + assert.ok(output.includes(fails), `expected "${fails}" in:\n${log}`); } else { - assert.equal(ok, true, output); + assert.equal(ok, true, log); } } finally { removeTree(dir); } }); } + +/* + * The folder contract, written out independently of check-v1.mjs so a typo + * in either shows up here. Source: the table in src/v1/README.md. Folders + * may always import themselves; pages/a and pages/b stand for two pages. + */ +const FOLDERS = { + entry: "", + app: "app", + "components/ui": "components/ui", + "components/patterns": "components/patterns", + shell: "shell", + "pages/a": "pages/a", + "pages/b": "pages/b", + services: "services", + stores: "stores", + hooks: "hooks", + lib: "lib", + styles: "styles", + plugins: "plugins", + test: "test", +}; +const EVERYTHING = Object.keys(FOLDERS); +const MAY_IMPORT = { + app: EVERYTHING, + plugins: EVERYTHING, + test: EVERYTHING, + "pages/a": EVERYTHING.filter((f) => f !== "pages/b"), + "components/ui": ["lib", "styles"], + "components/patterns": [ + "components/ui", + "lib", + "services", + "stores", + "hooks", + ], + shell: [ + "components/ui", + "components/patterns", + "services", + "stores", + "hooks", + "lib", + ], + services: ["lib"], + stores: ["lib"], + hooks: ["lib"], + lib: [], + styles: ["lib"], +}; + +test("folder contract: every folder pair, by file and by folder", () => { + const files = {}; + const expected = []; + for (const [from, allowed] of Object.entries(MAY_IMPORT)) { + for (const to of EVERYTHING) { + const rel = `src/v1/${FOLDERS[from]}/m-${to.replace("/", "-")}.ts`; + // The entry is a file, so it has only the one form. + files[rel] = + to === "entry" + ? 'import "@v1";\n' + : `import "@v1/${FOLDERS[to]}/target";\nimport "@v1/${FOLDERS[to]}";\n`; + if (from !== to && !allowed.includes(to)) { + const lines = to === "entry" ? [1] : [1, 2]; + for (const line of lines) { + expected.push(`${rel}:${line}: ${from}/ may not import from ${to}/`); + } + } + } + } + const reported = checkCode(files).map((l) => l.replace(/ \(.*$/, "")); + assert.deepEqual(reported.sort(), expected.sort()); +}); + +test("folder contract: every import form is read", () => { + // Each file breaks the contract once (components/ui -> services). + const forms = { + static: 'import { a } from "@v1/services/a";', + multiline: 'import {\n a,\n b,\n} from "@v1/services/a";', + type: 'import type { A } from "@v1/services/a";', + inlineType: 'import { type A } from "@v1/services/a";', + sideEffect: 'import "@v1/services/a";', + namespace: 'import * as a from "@v1/services/a";', + exportFrom: 'export { a } from "@v1/services/a";', + exportStar: 'export * from "@v1/services/a";', + exportStarAs: 'export * as a from "@v1/services/a";', + dynamic: 'export const a = () => import("@v1/services/a");', + dynamicTemplate: "export const a = () => import(`@v1/services/a`);", + require: 'export const a = require("@v1/services/a");', + relative: 'import { a } from "./../../services/a";', + afterSlashStarInString: + 'export const route = "/projects/*";\nimport { a } from "@v1/services/a";\n/** doc */', + afterSlashStarInComment: + '// matches /api/*\nimport { a } from "@v1/services/a";\n/** doc */', + afterDoubleSlashInString: + 'const u = "a//b"; export { a } from "@v1/services/a";', + glob: 'export const a = import.meta.glob("@v1/services/*.ts");', + afterTemplateExpression: + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + 'const t = `${"}"}/*`;\nimport { a } from "@v1/services/a";\n/** doc */', + }; + const files = {}; + for (const [form, code] of Object.entries(forms)) { + files[`src/v1/components/ui/${form}.ts`] = `${code}\n`; + } + const reported = checkCode(files); + for (const form of Object.keys(forms)) { + const file = `src/v1/components/ui/${form}.ts:`; + assert.ok( + reported.some((l) => l.startsWith(file)), + `${form}: not reported\n${reported.join("\n")}`, + ); + } +}); + +test("a symlinked file under v1 fails typecheck:v1", () => { + const dir = makeTree(null); + try { + fs.symlinkSync( + path.join(dir, "vite-env.d.ts"), + path.join(dir, "src", "v1", "lib", "linked.d.ts"), + ); + const { ok, output, log } = runAll(dir, ["typecheck:v1"]); + assert.equal(ok, false, log); + assert.ok(output.includes("No symlinks under v1"), log); + } finally { + removeTree(dir); + } +}); + +test("v1 imports nothing outside src/v1, in any spelling", () => { + const specs = [ + "@/helpers/x", + "../helpers/x", + "..", + "./../../helpers/x", + "/src/assets/logo.svg", + "src/helpers/x", + ]; + const files = {}; + specs.forEach((spec, i) => { + files[`src/v1/lib/outside-${i}.ts`] = + `export const a = () => import(\`${spec}\`);\n`; + }); + const reported = checkCode(files); + specs.forEach((spec, i) => { + assert.ok( + reported.some( + (l) => + l.startsWith(`src/v1/lib/outside-${i}.ts:`) && + l.includes("is outside src/v1"), + ), + `'${spec}': not reported\n${reported.join("\n")}`, + ); + }); +}); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx new file mode 100644 index 0000000000..f00b602b5d --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx @@ -0,0 +1,7 @@ +import { upper } from "@v1/lib/format"; + +type TagProps = { label: string }; + +export function Tag({ label }: TagProps) { + return {upper(label)}; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts new file mode 100644 index 0000000000..7ab5ff7d43 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/index.ts @@ -0,0 +1,3 @@ +import "@v1/styles/tokens.css"; + +export const themed = true; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css new file mode 100644 index 0000000000..ac38c30204 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/styles/tokens.css @@ -0,0 +1,3 @@ +:root { + --v1-accent: #000; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-ant-built-class/src/v1/components/ui/Tag.ts b/frontend/test-fixtures/v1-boundary/cases/v1-ant-built-class/src/v1/components/ui/Tag.ts new file mode 100644 index 0000000000..758e2c7350 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-ant-built-class/src/v1/components/ui/Tag.ts @@ -0,0 +1 @@ +export const tagClass = (kind: string): string => `ant-${kind}`; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts b/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts new file mode 100644 index 0000000000..fda637b2e3 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts @@ -0,0 +1,3 @@ +import { Button } from "antd"; + +export const AntButton = Button; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-antd-subpath/src/v1/components/ui/Button.ts b/frontend/test-fixtures/v1-boundary/cases/v1-antd-subpath/src/v1/components/ui/Button.ts new file mode 100644 index 0000000000..be5137ace6 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-antd-subpath/src/v1/components/ui/Button.ts @@ -0,0 +1,3 @@ +import Button from "antd/es/button"; + +export const AntButton = Button; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-imports-root-absolute/src/v1/lib/logo.ts b/frontend/test-fixtures/v1-boundary/cases/v1-imports-root-absolute/src/v1/lib/logo.ts new file mode 100644 index 0000000000..b306ec899d --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-imports-root-absolute/src/v1/lib/logo.ts @@ -0,0 +1,3 @@ +import logo from "/src/assets/logo.svg"; + +export const logoUrl = logo; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/components/ui/Helper.ts b/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/components/ui/Helper.ts new file mode 100644 index 0000000000..3ae7c334e1 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/components/ui/Helper.ts @@ -0,0 +1,3 @@ +import { render } from "@v1/test/render"; + +export const helper = render("x"); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/test/render.ts b/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/test/render.ts new file mode 100644 index 0000000000..a70ecac7f4 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-non-test-imports-test/src/v1/test/render.ts @@ -0,0 +1 @@ +export const render = (text: string): string => text; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-prop-types-subpath/src/v1/components/ui/Badge.ts b/frontend/test-fixtures/v1-boundary/cases/v1-prop-types-subpath/src/v1/components/ui/Badge.ts new file mode 100644 index 0000000000..cec0f63a16 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-prop-types-subpath/src/v1/components/ui/Badge.ts @@ -0,0 +1,3 @@ +import PropTypes from "prop-types/index"; + +export const badgeShape = PropTypes.string; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-root-file/src/v1/main.ts b/frontend/test-fixtures/v1-boundary/cases/v1-root-file/src/v1/main.ts new file mode 100644 index 0000000000..c8d21f3f94 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-root-file/src/v1/main.ts @@ -0,0 +1 @@ +export const main = 1; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-strict-null/src/v1/lib/length.ts b/frontend/test-fixtures/v1-boundary/cases/v1-strict-null/src/v1/lib/length.ts new file mode 100644 index 0000000000..b42e7ba01f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-strict-null/src/v1/lib/length.ts @@ -0,0 +1 @@ +export const length = (value: string | undefined): number => value.length; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-ant-class/src/v1/styles/global.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-ant-class/src/v1/styles/global.css new file mode 100644 index 0000000000..d077e5ea0f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-ant-class/src/v1/styles/global.css @@ -0,0 +1,3 @@ +.ant-btn { + color: red; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-styles-import-outside/src/v1/styles/global.css b/frontend/test-fixtures/v1-boundary/cases/v1-styles-import-outside/src/v1/styles/global.css new file mode 100644 index 0000000000..e5971a61a2 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-styles-import-outside/src/v1/styles/global.css @@ -0,0 +1 @@ +@import "../../index.css"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/components/ui/Link.test.ts b/frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/components/ui/Link.test.ts new file mode 100644 index 0000000000..a84d47e627 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/components/ui/Link.test.ts @@ -0,0 +1,3 @@ +import { projectsUrl } from "@v1/services/projects"; + +export const url = projectsUrl; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/services/projects.ts b/frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/services/projects.ts new file mode 100644 index 0000000000..934524c2f4 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-test-file-imports-services/src/v1/services/projects.ts @@ -0,0 +1 @@ +export const projectsUrl = "/api/v1/projects"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-unused-parameter/src/v1/lib/one.ts b/frontend/test-fixtures/v1-boundary/cases/v1-unused-parameter/src/v1/lib/one.ts new file mode 100644 index 0000000000..58bdb81c4e --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-unused-parameter/src/v1/lib/one.ts @@ -0,0 +1 @@ +export const one = (value: number): number => 1; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index e9882c5d8f..fecc6b40f5 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -184,7 +184,8 @@ export default defineConfig(({ mode }) => { resolve: { alias: { "@": path.resolve(import.meta.dirname, "./src"), - // v1 frontend; mirrored in src/v1/tsconfig.json and vitest.config.mjs. + // v1 frontend. Also in src/v1/tsconfig.json and vitest.config.mjs; + // tsconfig.json/jsconfig.json map only the entry, for v0. "@v1": path.resolve(import.meta.dirname, "./src/v1"), }, }, diff --git a/frontend/vitest.config.mjs b/frontend/vitest.config.mjs index 915c23d11d..25cbe07f68 100644 --- a/frontend/vitest.config.mjs +++ b/frontend/vitest.config.mjs @@ -110,7 +110,7 @@ export default defineConfig({ * of this tree entirely, so vitest's default glob no longer reaches it. * `build/` is listed because a production build lands there and its * bundled chunks would otherwise be walked. `test-fixtures/` holds - * deliberately broken trees (some with `*.test.ts` files) that + * known-good and known-bad trees (some with `*.test.ts` files) that * test-fixtures/v1-boundary/boundary.fixtures.mjs lints and type-checks; * they are inputs, not tests. */ From 87c803c626230b6db9d5c23b710ef088e5898f6b Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Tue, 27 Oct 2026 10:15:00 +0530 Subject: [PATCH 3/6] feat(frontend): run v1 checks in CI and tighten what pages may import (UN-4270) - ci-frontend-lint: the typecheck job also runs `typecheck:v1` and `lint:v1`, so the strict v1 tsconfig and the scripts/check-v1.mjs checks (folder contract, TypeScript-only files, PropTypes, `ant-`) gate PRs now rather than from P0-T10. - Pages may no longer import app/, test/ or the v1 entry, only the shared folders (components, shell, services, stores, hooks, lib, styles, plugins). Test files in a page may still import test/. Contract table, README and the fixture matrix updated together. --- .github/workflows/ci-frontend-lint.yaml | 12 ++++++++++++ frontend/scripts/check-v1.mjs | 15 +++++++++++++-- frontend/src/v1/README.md | 2 +- .../v1-boundary/boundary.fixtures.mjs | 12 +++++++++++- .../src/v1/pages/projects/title.test.ts | 4 ++++ 5 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.test.ts diff --git a/.github/workflows/ci-frontend-lint.yaml b/.github/workflows/ci-frontend-lint.yaml index 1c87375a96..2b5fbac9c0 100644 --- a/.github/workflows/ci-frontend-lint.yaml +++ b/.github/workflows/ci-frontend-lint.yaml @@ -121,6 +121,18 @@ jobs: 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 rejects non-TS files. + - name: Run v1 typecheck + working-directory: frontend + run: bun run typecheck:v1 + + # Biome on src/v1, then the folder import contract, PropTypes and `ant-` + # checks in scripts/check-v1.mjs, which `biome ci` cannot express. + - name: Run v1 lint + working-directory: frontend + run: bun run lint: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/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs index e5d6612ae9..1096abaf8e 100755 --- a/frontend/scripts/check-v1.mjs +++ b/frontend/scripts/check-v1.mjs @@ -33,7 +33,8 @@ const runCode = args.includes("--code") || !args.includes("--files"); const ALL = "*"; /* * Folder -> folders it may import from, besides itself. `entry` is - * src/v1/index.ts. Pages may import everything except other pages. + * src/v1/index.ts. Pages may not import other pages, app/, test/ (except + * from test files) or the entry. * Mirrors the table in src/v1/README.md; change both together. */ const CONTRACT = { @@ -41,7 +42,17 @@ const CONTRACT = { app: ALL, plugins: ALL, test: ALL, - pages: ALL, + pages: [ + "components/ui", + "components/patterns", + "shell", + "services", + "stores", + "hooks", + "lib", + "styles", + "plugins", + ], "components/ui": ["lib", "styles"], "components/patterns": [ "components/ui", diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 181b42bfaa..aeb7dfb32f 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -28,7 +28,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | `components/ui/` | Core components | `lib/`, `styles/` | | `components/patterns/` | Composites built from core components | `components/ui/`, `lib/`, `services/`, `stores/`, `hooks/` | | `shell/` | Top bar, rail, context bar, tab bar | `components/ui/`, `components/patterns/`, `services/`, `stores/`, `hooks/`, `lib/` | -| `pages//` | One folder per target page | everything except other pages | +| `pages//` | One folder per target page | `components/ui/`, `components/patterns/`, `shell/`, `services/`, `stores/`, `hooks/`, `lib/`, `styles/`, `plugins/` — not other pages, `app/`, `test/` or the entry | | `services/` | Typed service modules; the only place a URL appears | `lib/` | | `stores/` | Zustand stores | `lib/` | | `hooks/` | Shared hooks | `lib/` | diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index 26902335b9..1c7852a4b6 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -343,7 +343,17 @@ const MAY_IMPORT = { app: EVERYTHING, plugins: EVERYTHING, test: EVERYTHING, - "pages/a": EVERYTHING.filter((f) => f !== "pages/b"), + "pages/a": [ + "components/ui", + "components/patterns", + "shell", + "services", + "stores", + "hooks", + "lib", + "styles", + "plugins", + ], "components/ui": ["lib", "styles"], "components/patterns": [ "components/ui", diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.test.ts b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.test.ts new file mode 100644 index 0000000000..73138391d0 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/pages/projects/title.test.ts @@ -0,0 +1,4 @@ +import { renderLabel } from "@v1/test/render"; +import { title } from "./title"; + +export const rendered = renderLabel(title); From f86de0b6e5d4b3cb14674d4ea3c188393e102fe8 Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Tue, 27 Oct 2026 10:21:09 +0530 Subject: [PATCH 4/6] refactor(frontend): address SonarCloud findings on the v1 guardrails (UN-4270) check-v1.mjs: split the comment scanner into small helpers (cognitive complexity 40 -> under 15), drop the nested ternary and nested template literal, use String.raw for the import regexes, keep CLI flags in a Set. Fixture runner: - Cases and the expected folder contract are parsed text tables instead of repeated object literals (the 10% duplication). - Sorts use an explicit localeCompare comparator. - The contract matrix is built by a helper. - Spawns /bin/sh by absolute path and no longer needs `git init` (Biome only needs the ignore file). The git check-ignore call keeps a PATH lookup, with the reason and a NOSONAR marker. Fixture cases: re-exports rewritten as small functions and read-only props, without changing what each case exercises. --- frontend/scripts/check-v1.mjs | 118 ++++--- .../v1-boundary/boundary.fixtures.mjs | 312 ++++++------------ .../v0-imports-v1-internal/src/pages/Bad.jsx | 2 +- .../v0-imports-v1-relative/src/pages/Bad.jsx | 2 +- .../src/v1/components/ui/Tag.tsx | 2 +- .../src/v1/components/ui/Button.ts | 2 +- .../v1-imports-v0-alias/src/v1/lib/bad.ts | 2 +- .../v1-imports-v0-relative/src/v1/lib/bad.ts | 2 +- .../src/v1/pages/projects/index.ts | 2 +- .../src/v1/components/ui/ProjectLink.tsx | 2 +- 10 files changed, 180 insertions(+), 266 deletions(-) diff --git a/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs index 1096abaf8e..0866dec496 100755 --- a/frontend/scripts/check-v1.mjs +++ b/frontend/scripts/check-v1.mjs @@ -26,9 +26,9 @@ import fs from "node:fs"; import path from "node:path"; const ROOT = path.join(import.meta.dirname, "..", "src", "v1"); -const args = process.argv.slice(2); -const runFiles = args.includes("--files") || !args.includes("--code"); -const runCode = args.includes("--code") || !args.includes("--files"); +const args = new Set(process.argv.slice(2)); +const runFiles = args.has("--files") || !args.has("--code"); +const runCode = args.has("--code") || !args.has("--files"); const ALL = "*"; /* @@ -152,66 +152,86 @@ function blankComments(src) { // "`" = inside a template literal; "{" = inside a `${}` or a brace in one. const stack = []; while (i < src.length) { - const c = src[i]; - const next = src[i + 1]; + let end; if (stack.at(-1) === "`") { - if (c === "\\") { - out += src.slice(i, i + 2); - i += 2; - } else if (c === "`") { - stack.pop(); - out += c; - i += 1; - } else if (c === "$" && next === "{") { - stack.push("{"); - out += "${"; - i += 2; - } else { - out += c; - i += 1; - } - continue; - } - if (c === "/" && (next === "/" || next === "*")) { - const close = - next === "/" ? src.indexOf("\n", i) : src.indexOf("*/", i + 2); - const end = close === -1 ? src.length : next === "/" ? close : close + 2; + end = i + templateStep(src, i, stack); + } else if (src[i] === "/" && (src[i + 1] === "/" || src[i + 1] === "*")) { + end = commentEnd(src, i); out += src.slice(i, end).replace(/[^\n]/g, " "); i = end; - } else if (c === '"' || c === "'") { - let j = i + 1; - while (j < src.length && src[j] !== c && src[j] !== "\n") { - j += src[j] === "\\" ? 2 : 1; - } - out += src.slice(i, j + 1); - i = j + 1; + continue; + } else if (src[i] === '"' || src[i] === "'") { + end = stringEnd(src, i); } else { - if (c === "`") { - stack.push("`"); - } else if (c === "{" && stack.length > 0) { - stack.push("{"); - } else if (c === "}" && stack.length > 0) { - stack.pop(); - } - out += c; - i += 1; + codeStep(src[i], stack); + end = i + 1; } + out += src.slice(i, end); + i = end; } return out; } +/* Index just past the line or block comment that starts at i. */ +function commentEnd(src, i) { + if (src[i + 1] === "/") { + const newline = src.indexOf("\n", i); + return newline === -1 ? src.length : newline; + } + const close = src.indexOf("*/", i + 2); + return close === -1 ? src.length : close + 2; +} + +/* Index just past the '...' or "..." string that starts at i. An unclosed + * string ends at the newline. */ +function stringEnd(src, i) { + let j = i + 1; + while (j < src.length && src[j] !== src[i] && src[j] !== "\n") { + j += src[j] === "\\" ? 2 : 1; + } + return j + 1; +} + +/* One step inside a template literal: how many characters to copy. Closing + * the literal or opening `${` updates the stack. */ +function templateStep(src, i, stack) { + if (src[i] === "\\") { + return 2; + } + if (src[i] === "`") { + stack.pop(); + return 1; + } + if (src[i] === "$" && src[i + 1] === "{") { + stack.push("{"); + return 2; + } + return 1; +} + +/* Tracks template literals and the braces inside their `${}` expressions. */ +function codeStep(c, stack) { + if (c === "`") { + stack.push("`"); + } else if (c === "{" && stack.length > 0) { + stack.push("{"); + } else if (c === "}" && stack.length > 0) { + stack.pop(); + } +} + // A specifier: a quoted or backtick string with no interpolation. const SPEC = "[\"'`]([^\"'`$]+)[\"'`]"; const IMPORT_RES = [ new RegExp( - `\\b(?:import|export)\\s+(?:type\\s+)?(?:[\\w$*{}\\s,]+?\\s+from\\s+)?${SPEC}`, + String.raw`\b(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]+?\s+from\s+)?${SPEC}`, "g", ), - new RegExp(`\\bimport\\s*\\(\\s*${SPEC}\\s*[,)]`, "g"), - new RegExp(`\\brequire\\s*\\(\\s*${SPEC}\\s*\\)`, "g"), + new RegExp(String.raw`\bimport\s*\(\s*${SPEC}\s*[,)]`, "g"), + new RegExp(String.raw`\brequire\s*\(\s*${SPEC}\s*\)`, "g"), // Vite globs; the first pattern of an array is checked. new RegExp( - `\\bimport\\.meta\\.glob\\s*(?:<[^>]*>)?\\(\\s*\\[?\\s*${SPEC}`, + String.raw`\bimport\.meta\.glob\s*(?:<[^>]*>)?\(\s*\[?\s*${SPEC}`, "g", ), ]; @@ -267,8 +287,10 @@ function walk(dir) { } const problems = []; -const report = (rel, line, msg) => - problems.push(`src/v1/${rel}${line ? `:${line}` : ""}: ${msg}`); +const report = (rel, line, msg) => { + const where = line ? `${rel}:${line}` : rel; + problems.push(`src/v1/${where}: ${msg}`); +}; if (!fs.existsSync(ROOT)) { console.error(`check-v1: ${ROOT} does not exist`); diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index 1c7852a4b6..fb84fc072c 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -11,7 +11,7 @@ * pre-commit hook and typecheck never see them. */ import assert from "node:assert/strict"; -import { execFileSync, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -23,162 +23,69 @@ const { scripts } = JSON.parse( fs.readFileSync(path.join(FRONTEND, "package.json"), "utf8"), ); -/* - * `run` holds package.json script names, or raw commands for the v0 side, - * which has no v1 script. `fails` is a substring the tool output must - * contain; cases without it must pass. - */ -const CASES = [ - // The empty scaffold passes everything. - { name: null, run: ["typecheck:v1", "lint:v1"] }, - - // Import boundary, v1 -> v0 (Biome). - { - name: "v1-imports-v0-alias", - run: ["lint:v1"], - fails: "v1 imports nothing outside src/v1", - }, - { - name: "v1-imports-v0-relative", - run: ["lint:v1"], - fails: "v1 imports nothing outside src/v1", - }, - { - name: "v1-imports-root-absolute", - run: ["lint:v1"], - fails: "v1 imports nothing outside src/v1", - }, - - // Import boundary, v0 -> v1 (Biome). - { - name: "v0-imports-v1-internal", - run: ["biome lint src/"], - fails: "v0 may import only the v1 public entry", - }, - { - name: "v0-imports-v1-relative", - run: ["biome lint src/"], - fails: "v0 may import only the v1 public entry", - }, - { name: "v0-imports-v1-entry", run: ["biome lint src/"] }, - - // Banned under v1. - { name: "v1-prop-types", run: ["lint:v1"], fails: "PropTypes are not used" }, - { - name: "v1-prop-types-assignment", - run: ["lint:v1"], - fails: "No PropTypes under v1", - }, - { - name: "v1-antd-import", - run: ["lint:v1"], - fails: "antd is not used under src/v1", - }, - { - name: "v1-antd-subpath", - run: ["lint:v1"], - fails: "antd is not used under src/v1", - }, - { - name: "v1-prop-types-subpath", - run: ["lint:v1"], - fails: "PropTypes are not used", - }, - { - name: "v1-styles-import-outside", - run: ["lint:v1"], - fails: "v1 styles import nothing outside it", - }, - { - name: "v1-styles-ant-class", - run: ["lint:v1"], - fails: "No antd 'ant-' class names", - }, - { - name: "v1-component-css-import", - run: ["lint:v1"], - fails: "No component .css under src/v1", - }, - { - name: "v1-component-css-file", - run: ["typecheck:v1"], - fails: "No component style files", - }, - { - name: "v1-ant-class-prefix", - run: ["lint:v1"], - fails: "No antd 'ant-' class names", - }, - { - name: "v1-ant-built-class", - run: ["lint:v1"], - fails: "No antd 'ant-' class names", - }, - { name: "v1-explicit-any", run: ["lint:v1"], fails: "noExplicitAny" }, - // The v1 override must add to the shared rules, not replace them. - { - name: "v1-base-rules-still-apply", - run: ["lint:v1"], - fails: "noDoubleEquals", - }, +/* Rows of "a | b | c" cells; blank lines and "#" comments are skipped. */ +function parseTable(text) { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + .map((line) => line.split("|").map((cell) => cell.trim())); +} - // TypeScript only, strict. - { name: "v1-js-file", run: ["typecheck:v1"], fails: "TypeScript only" }, - { - name: "v1-implicit-any", - run: ["typecheck:v1"], - fails: "TS7006", - }, - { - name: "v1-strict-null", - run: ["typecheck:v1"], - fails: "TS18048", - }, - { - name: "v1-unused-local", - run: ["typecheck:v1"], - fails: "TS6133", - }, - { - name: "v1-unused-parameter", - run: ["typecheck:v1"], - fails: "TS6133", - }, +const sorted = (items) => items.toSorted((a, b) => a.localeCompare(b)); - // Folder taxonomy and contract (the full contract is the matrix below). - { - name: "v1-outside-taxonomy", - run: ["typecheck:v1"], - fails: "Outside the v1 folder taxonomy", - }, - { - name: "v1-root-file", - run: ["typecheck:v1"], - fails: "Outside the v1 folder taxonomy", - }, - { - name: "v1-ui-imports-services", - run: ["lint:v1"], - fails: "components/ui/ may not import from services/", - }, - { - name: "v1-page-imports-other-page", - run: ["lint:v1"], - fails: "pages/projects/ may not import from pages/settings/", - }, - // Only test files may use test/, and being a test file allows nothing else. - { - name: "v1-non-test-imports-test", - run: ["lint:v1"], - fails: "components/ui/ may not import from test/", - }, - { - name: "v1-test-file-imports-services", - run: ["lint:v1"], - fails: "components/ui/ may not import from services/", - }, - { name: "v1-allowed-imports", run: ["typecheck:v1", "lint:v1"] }, -]; +/* + * One case per row: the folder under cases/ ("-" for the empty scaffold); + * the steps, joined by "+", each a package.json script name or a raw command + * (the v0 side has no v1 script); and a substring the tool output must + * contain, or "-" when the steps must pass. + */ +const CASES = parseTable(` + # The empty scaffold passes everything. + - | typecheck:v1 + lint:v1 | - + # Import boundary, v1 -> v0 (Biome). + v1-imports-v0-alias | lint:v1 | v1 imports nothing outside src/v1 + v1-imports-v0-relative | lint:v1 | v1 imports nothing outside src/v1 + v1-imports-root-absolute | lint:v1 | v1 imports nothing outside src/v1 + # Import boundary, v0 -> v1 (Biome). + v0-imports-v1-internal | biome lint src/ | v0 may import only the v1 public entry + v0-imports-v1-relative | biome lint src/ | v0 may import only the v1 public entry + v0-imports-v1-entry | biome lint src/ | - + # Banned under v1. + v1-prop-types | lint:v1 | PropTypes are not used + v1-prop-types-subpath | lint:v1 | PropTypes are not used + v1-prop-types-assignment | lint:v1 | No PropTypes under v1 + v1-antd-import | lint:v1 | antd is not used under src/v1 + v1-antd-subpath | lint:v1 | antd is not used under src/v1 + v1-styles-import-outside | lint:v1 | v1 styles import nothing outside it + v1-styles-ant-class | lint:v1 | No antd 'ant-' class names + v1-component-css-import | lint:v1 | No component .css under src/v1 + v1-component-css-file | typecheck:v1 | No component style files + v1-ant-class-prefix | lint:v1 | No antd 'ant-' class names + v1-ant-built-class | lint:v1 | No antd 'ant-' class names + v1-explicit-any | lint:v1 | noExplicitAny + # The v1 override must add to the shared rules, not replace them. + v1-base-rules-still-apply | lint:v1 | noDoubleEquals + # TypeScript only, strict. + v1-js-file | typecheck:v1 | TypeScript only + v1-implicit-any | typecheck:v1 | TS7006 + v1-strict-null | typecheck:v1 | TS18048 + v1-unused-local | typecheck:v1 | TS6133 + v1-unused-parameter | typecheck:v1 | TS6133 + # Folder taxonomy and contract (the full contract is the matrix below). + v1-outside-taxonomy | typecheck:v1 | Outside the v1 folder taxonomy + v1-root-file | typecheck:v1 | Outside the v1 folder taxonomy + v1-ui-imports-services | lint:v1 | components/ui/ may not import from services/ + v1-page-imports-other-page | lint:v1 | pages/projects/ may not import from pages/settings/ + # Only test files may use test/, and being a test file allows nothing else. + v1-non-test-imports-test | lint:v1 | components/ui/ may not import from test/ + v1-test-file-imports-services | lint:v1 | components/ui/ may not import from services/ + v1-allowed-imports | typecheck:v1 + lint:v1 | - +`).map(([name, run, fails]) => ({ + name: name === "-" ? null : name, + run: run.split(" + "), + fails: fails === "-" ? null : fails, +})); function makeTree(caseName) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "v1-boundary-")); @@ -200,9 +107,8 @@ function makeTree(caseName) { if (caseName) { fs.cpSync(path.join(CASES_DIR, caseName), dir, { recursive: true }); } - // biome.json enables VCS integration with `useIgnoreFile`, which needs a - // repository root holding an ignore file. - execFileSync("git", ["init", "-q"], { cwd: dir }); + // biome.json enables VCS integration with `useIgnoreFile`, which needs an + // ignore file at the root. fs.writeFileSync(path.join(dir, ".gitignore"), "node_modules\n"); return dir; } @@ -229,7 +135,7 @@ function runAll(dir, run) { let log = ""; for (const step of run) { const command = scripts[step] ?? step; - const result = spawnSync("sh", ["-c", command], { + const result = spawnSync("/bin/sh", ["-c", command], { cwd: dir, encoding: "utf8", env: { @@ -285,18 +191,18 @@ test("no v1 or fixture file is git-ignored", () => { ); // A lib/ folder anywhere under v1, not only the top-level one. files.push("src/v1/pages/projects/lib/x.ts", "src/v1/lib/sub/lib/x.ts"); - const result = spawnSync("git", ["check-ignore", "--no-index", "--stdin"], { - cwd: FRONTEND, - encoding: "utf8", - input: files.join("\n"), - }); + const gitArgs = ["check-ignore", "--no-index", "--stdin"]; + const options = { cwd: FRONTEND, encoding: "utf8", input: files.join("\n") }; + // git from PATH on purpose: this is a developer/CI test, and git has no + // fixed location across macOS and Linux. + const result = spawnSync("git", gitArgs, options); // NOSONAR // Exit 1 means "nothing matched"; 0 means some file is ignored. assert.equal(result.status, 1, `git-ignored:\n${result.stdout}`); }); test("every case folder is listed", () => { const listed = new Set(CASES.map((c) => c.name).filter(Boolean)); - assert.deepEqual(fs.readdirSync(CASES_DIR).sort(), [...listed].sort()); + assert.deepEqual(sorted(fs.readdirSync(CASES_DIR)), sorted([...listed])); }); for (const { name, run, fails } of CASES) { @@ -339,65 +245,51 @@ const FOLDERS = { test: "test", }; const EVERYTHING = Object.keys(FOLDERS); -const MAY_IMPORT = { - app: EVERYTHING, - plugins: EVERYTHING, - test: EVERYTHING, - "pages/a": [ - "components/ui", - "components/patterns", - "shell", - "services", - "stores", - "hooks", - "lib", - "styles", - "plugins", - ], - "components/ui": ["lib", "styles"], - "components/patterns": [ - "components/ui", - "lib", - "services", - "stores", - "hooks", - ], - shell: [ - "components/ui", - "components/patterns", - "services", - "stores", - "hooks", - "lib", - ], - services: ["lib"], - stores: ["lib"], - hooks: ["lib"], - lib: [], - styles: ["lib"], -}; +const MAY_IMPORT = Object.fromEntries( + parseTable(` + app | * + plugins | * + test | * + pages/a | components/ui components/patterns shell services stores hooks lib styles plugins + components/ui | lib styles + components/patterns | components/ui lib services stores hooks + shell | components/ui components/patterns services stores hooks lib + services | lib + stores | lib + hooks | lib + lib | + styles | lib + `).map(([from, to]) => [from, to === "*" ? EVERYTHING : to.split(/\s+/)]), +); -test("folder contract: every folder pair, by file and by folder", () => { +/* One file per folder pair, importing the target by file and by folder; and + * the problems check-v1 must report for them. */ +function contractMatrix() { const files = {}; const expected = []; for (const [from, allowed] of Object.entries(MAY_IMPORT)) { for (const to of EVERYTHING) { const rel = `src/v1/${FOLDERS[from]}/m-${to.replace("/", "-")}.ts`; // The entry is a file, so it has only the one form. + const target = FOLDERS[to]; files[rel] = to === "entry" ? 'import "@v1";\n' - : `import "@v1/${FOLDERS[to]}/target";\nimport "@v1/${FOLDERS[to]}";\n`; - if (from !== to && !allowed.includes(to)) { - const lines = to === "entry" ? [1] : [1, 2]; - for (const line of lines) { - expected.push(`${rel}:${line}: ${from}/ may not import from ${to}/`); - } + : `import "@v1/${target}/target";\nimport "@v1/${target}";\n`; + const denied = from !== to && !allowed.includes(to); + const lines = to === "entry" ? [1] : [1, 2]; + for (const line of denied ? lines : []) { + expected.push(`${rel}:${line}: ${from}/ may not import from ${to}/`); } } } + return { files, expected }; +} + +test("folder contract: every folder pair, by file and by folder", () => { + const { files, expected } = contractMatrix(); const reported = checkCode(files).map((l) => l.replace(/ \(.*$/, "")); - assert.deepEqual(reported.sort(), expected.sort()); + assert.deepEqual(sorted(reported), sorted(expected)); }); test("folder contract: every import form is read", () => { diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx index d822a2d078..ecddfc6910 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-internal/src/pages/Bad.jsx @@ -1,3 +1,3 @@ import { pluginManifest } from "@v1/plugins/manifest"; -export const manifest = pluginManifest; +export const manifestSize = () => pluginManifest.length; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx index d462e4d68a..953cb550c6 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-relative/src/pages/Bad.jsx @@ -1,3 +1,3 @@ import { pluginManifest } from "../v1/plugins/manifest"; -export const manifest = pluginManifest; +export const manifestSize = () => pluginManifest.length; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx index f00b602b5d..07afb25230 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Tag.tsx @@ -1,6 +1,6 @@ import { upper } from "@v1/lib/format"; -type TagProps = { label: string }; +type TagProps = Readonly<{ label: string }>; export function Tag({ label }: TagProps) { return {upper(label)}; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts b/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts index fda637b2e3..1973bc6c11 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-antd-import/src/v1/components/ui/Button.ts @@ -1,3 +1,3 @@ import { Button } from "antd"; -export const AntButton = Button; +export const buttonName = Button.name; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts index b6e5854b2b..4f64a2f1e2 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-alias/src/v1/lib/bad.ts @@ -1,3 +1,3 @@ import { getBaseUrl } from "@/helpers/GetStaticData"; -export const base = getBaseUrl; +export const base = () => getBaseUrl(); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts index d2c250ae68..acba160b3c 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-imports-v0-relative/src/v1/lib/bad.ts @@ -1,3 +1,3 @@ import { getBaseUrl } from "../../helpers/GetStaticData"; -export const base = getBaseUrl; +export const base = () => getBaseUrl(); diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts b/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts index 1a075e1cc3..f6e2ea9482 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts +++ b/frontend/test-fixtures/v1-boundary/cases/v1-page-imports-other-page/src/v1/pages/projects/index.ts @@ -1,3 +1,3 @@ import { settingsTitle } from "@v1/pages/settings"; -export const title = settingsTitle; +export const title = `Projects · ${settingsTitle}`; diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx index 3863836b20..8024a7c0bb 100644 --- a/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx +++ b/frontend/test-fixtures/v1-boundary/cases/v1-ui-imports-services/src/v1/components/ui/ProjectLink.tsx @@ -1,3 +1,3 @@ import { projectsUrl } from "@v1/services/projects"; -export const href = projectsUrl; +export const href = `${projectsUrl}/`; From 67be34b11e113804f7b1e06ddcedc47e874c26c6 Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Tue, 27 Oct 2026 10:22:45 +0530 Subject: [PATCH 5/6] refactor(frontend): split the contract-matrix builder to cut its complexity (UN-4270) --- .../v1-boundary/boundary.fixtures.mjs | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index fb84fc072c..6b2aeaa56d 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -262,25 +262,30 @@ const MAY_IMPORT = Object.fromEntries( `).map(([from, to]) => [from, to === "*" ? EVERYTHING : to.split(/\s+/)]), ); -/* One file per folder pair, importing the target by file and by folder; and - * the problems check-v1 must report for them. */ +/* The file for one folder pair: it imports the target by file and by folder. + * The entry is a file, so it has only the one form. */ +function pairFile(to) { + const target = FOLDERS[to]; + return to === "entry" + ? ['import "@v1";'] + : [`import "@v1/${target}/target";`, `import "@v1/${target}";`]; +} + +/* One file per folder pair, and the problems check-v1 must report for them. */ function contractMatrix() { const files = {}; const expected = []; for (const [from, allowed] of Object.entries(MAY_IMPORT)) { for (const to of EVERYTHING) { const rel = `src/v1/${FOLDERS[from]}/m-${to.replace("/", "-")}.ts`; - // The entry is a file, so it has only the one form. - const target = FOLDERS[to]; - files[rel] = - to === "entry" - ? 'import "@v1";\n' - : `import "@v1/${target}/target";\nimport "@v1/${target}";\n`; + const imports = pairFile(to); + files[rel] = `${imports.join("\n")}\n`; const denied = from !== to && !allowed.includes(to); - const lines = to === "entry" ? [1] : [1, 2]; - for (const line of denied ? lines : []) { - expected.push(`${rel}:${line}: ${from}/ may not import from ${to}/`); - } + expected.push( + ...(denied ? imports : []).map( + (_, i) => `${rel}:${i + 1}: ${from}/ may not import from ${to}/`, + ), + ); } } return { files, expected }; From 81e15b1597413bac74ae4f4a4e476577326fc836 Mon Sep 17 00:00:00 2001 From: Jaseem Jas Date: Tue, 27 Oct 2026 12:55:55 +0530 Subject: [PATCH 6/6] fix(frontend): check every glob pattern and v0 -> v1 imports Biome cannot see (UN-4270) Addresses Greptile review on #2380: - import.meta.glob([...]): every pattern in the array is checked, not only the first, so an allowed first pattern no longer hides a forbidden one. - check-v1 --code also scans v0 (src/ outside v1, including the cloud src/plugins overlay) for template-literal imports, globs, require(), @/v1, src/v1, relative paths and CSS @import that reach into v1 other than through `@v1`. Biome still covers plain imports. Fixtures: Greptile's example as a case, a glob array with a forbidden later pattern, and a generated test of every v0 -> v1 form (denied and allowed). The v0 scan reports nothing on the current tree. --- frontend/scripts/check-v1.mjs | 137 +++++++++++++----- frontend/src/v1/README.md | 2 +- .../v1-boundary/boundary.fixtures.mjs | 43 +++++- .../v0-imports-v1-template/src/pages/Bad.jsx | 1 + 4 files changed, 148 insertions(+), 35 deletions(-) create mode 100644 frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-template/src/pages/Bad.jsx diff --git a/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs index 0866dec496..3e842480c4 100755 --- a/frontend/scripts/check-v1.mjs +++ b/frontend/scripts/check-v1.mjs @@ -7,15 +7,17 @@ * file inside the v1 folder taxonomy. Run by `typecheck:v1`. * --code Imports: nothing outside src/v1 and the folder import contract * (which v1 folder may import which). No PropTypes, no antd `ant-` - * class names. Run by `lint:v1`. + * class names. Also scans v0 (the rest of src/) for imports that + * reach into v1 other than through `@v1`. Run by `lint:v1`. * * With neither flag, both run. It always checks the src/v1 beside this * script; the fixtures in test-fixtures/v1-boundary/ copy it into each * throwaway tree for that reason. * * The v0 <-> v1 boundary is also a Biome rule (biome.json `overrides`), so CI - * and editors report it. The script repeats the v1 -> outside half because it - * also reads template-literal imports, which Biome does not. Biome overrides + * and editors report it. The script repeats both halves because it also reads + * template-literal imports, Vite globs, require() and CSS @import, which + * Biome does not. Biome overrides * replace rule options rather than merge them, which is why the per-folder * contract lives here and not as one override per folder. * @@ -25,7 +27,8 @@ import fs from "node:fs"; import path from "node:path"; -const ROOT = path.join(import.meta.dirname, "..", "src", "v1"); +const SRC = path.join(import.meta.dirname, "..", "src"); +const ROOT = path.join(SRC, "v1"); const args = new Set(process.argv.slice(2)); const runFiles = args.has("--files") || !args.has("--code"); const runCode = args.has("--code") || !args.has("--files"); @@ -229,12 +232,28 @@ const IMPORT_RES = [ ), new RegExp(String.raw`\bimport\s*\(\s*${SPEC}\s*[,)]`, "g"), new RegExp(String.raw`\brequire\s*\(\s*${SPEC}\s*\)`, "g"), - // Vite globs; the first pattern of an array is checked. - new RegExp( - String.raw`\bimport\.meta\.glob\s*(?:<[^>]*>)?\(\s*\[?\s*${SPEC}`, - "g", - ), ]; +// Vite globs: one pattern or an array of them. Every pattern is checked. +const GLOB_RE = new RegExp( + String.raw`\bimport\.meta\.glob\s*(?:<[^>]*>)?\(\s*(\[[^\]]*\]|${SPEC})`, + "g", +); +const SPEC_RE = new RegExp(SPEC, "g"); + +/* Every import specifier in comment-blanked source, with its offset. */ +function* importSpecs(text) { + for (const re of IMPORT_RES) { + for (const m of text.matchAll(re)) { + yield { spec: m[1], index: m.index }; + } + } + for (const m of text.matchAll(GLOB_RE)) { + const start = m.index + m[0].indexOf(m[1]); + for (const s of m[1].matchAll(SPEC_RE)) { + yield { spec: s[1], index: start + s.index }; + } + } +} // CSS `@import` and `url()` targets, for the files in styles/. const CSS_IMPORT_RE = /(?:@import\s+(?:url\(\s*)?|url\(\s*)["']?([^"')\s;]+)/g; @@ -280,10 +299,10 @@ function walk(dir) { .filter((d) => (d.isFile() || d.isSymbolicLink()) && !IGNORED.has(d.name)) .map((d) => path - .relative(ROOT, path.join(d.parentPath, d.name)) + .relative(dir, path.join(d.parentPath, d.name)) .replaceAll(path.sep, "/"), ) - .sort(); + .toSorted((a, b) => a.localeCompare(b)); } const problems = []; @@ -347,28 +366,26 @@ for (const rel of walk(ROOT)) { const text = blankComments(fs.readFileSync(path.join(ROOT, rel), "utf8")); const isTestFile = TEST_FILE.test(name); - for (const re of IMPORT_RES) { - for (const m of text.matchAll(re)) { - const to = targetFolder(m[1], rel); - const line = lineAt(text, m.index); - if (to === undefined) { - continue; - } - if (to === "outside") { - report( - rel, - line, - `'${m[1]}' is outside src/v1; v1 imports nothing outside it.`, - ); - } else if (to === null) { - report(rel, line, `'${m[1]}' is outside the v1 folder taxonomy.`); - } else if (!mayImport(folder, to, isTestFile)) { - report( - rel, - line, - `${folder}/ may not import from ${to}/ ('${m[1]}'); see the folder contract in src/v1/README.md.`, - ); - } + for (const { spec, index } of importSpecs(text)) { + const to = targetFolder(spec, rel); + const line = lineAt(text, index); + if (to === undefined) { + continue; + } + if (to === "outside") { + report( + rel, + line, + `'${spec}' is outside src/v1; v1 imports nothing outside it.`, + ); + } else if (to === null) { + report(rel, line, `'${spec}' is outside the v1 folder taxonomy.`); + } else if (!mayImport(folder, to, isTestFile)) { + report( + rel, + line, + `${folder}/ may not import from ${to}/ ('${spec}'); see the folder contract in src/v1/README.md.`, + ); } } for (const m of text.matchAll(/\.propTypes\b|\bpropTypes\s*[:=]/g)) { @@ -384,6 +401,60 @@ for (const rel of walk(ROOT)) { } } +/* + * The v0 -> v1 half: v0 (everything in src/ outside v1, including the cloud + * src/plugins/ overlay) reaches v1 only through the `@v1` entry. + */ +const V0_CODE_EXT = /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts)$/; + +/* Whether a v0 specifier (fileRel is relative to src/) reaches into v1. */ +function intoV1(spec, fileRel) { + if (spec === "@v1" || /^@v1\/index(\.ts)?$/.test(spec)) { + return false; + } + if (spec.startsWith("@v1/") || /^(@\/|src\/)v1(\/|$)/.test(spec)) { + return true; + } + if (!spec.startsWith(".")) { + return false; + } + const rel = path.posix.join(path.posix.dirname(fileRel), spec); + return rel === "v1" || rel.startsWith("v1/"); +} + +function v0Specs(rel, text) { + if (!STYLE_EXT.test(rel)) { + return importSpecs(text); + } + return [...text.matchAll(CSS_IMPORT_RE)].map((m) => ({ + spec: m[1], + index: m.index, + })); +} + +function checkV0() { + for (const rel of walk(SRC)) { + const full = path.join(SRC, rel); + const inV1 = rel.startsWith("v1/"); + const scanned = STYLE_EXT.test(rel) || V0_CODE_EXT.test(rel); + if (inV1 || !scanned || fs.lstatSync(full).isSymbolicLink()) { + continue; + } + const text = blankComments(fs.readFileSync(full, "utf8")); + for (const { spec, index } of v0Specs(rel, text)) { + if (intoV1(spec, rel)) { + problems.push( + `src/${rel}:${lineAt(text, index)}: '${spec}' reaches into src/v1; v0 may import only the v1 public entry, as '@v1'.`, + ); + } + } + } +} + +if (runCode) { + checkV0(); +} + if (problems.length > 0) { console.error(problems.join("\n")); console.error(`\ncheck-v1: ${problems.length} problem(s).`); diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index aeb7dfb32f..61fa459ac3 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -42,7 +42,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`, | Script | Checks | | --- | --- | | `bun run typecheck:v1` | TypeScript-only files, CSS only in `styles/`, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | -| `bun run lint:v1` | Biome on `src/v1/`, then imports (nothing outside v1, folder contract), PropTypes and `ant-` checks | +| `bun run lint:v1` | Biome on `src/v1/`, then imports (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks | | `bun run test:v1` | v1 unit tests, then the guardrail fixtures in `test-fixtures/v1-boundary/` | The v0 ↔ v1 boundary and the banned imports are Biome rules in `biome.json`, diff --git a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs index 6b2aeaa56d..c530319d89 100644 --- a/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -50,6 +50,7 @@ const CASES = parseTable(` # Import boundary, v0 -> v1 (Biome). v0-imports-v1-internal | biome lint src/ | v0 may import only the v1 public entry v0-imports-v1-relative | biome lint src/ | v0 may import only the v1 public entry + v0-imports-v1-template | lint:v1 | v0 may import only the v1 public entry v0-imports-v1-entry | biome lint src/ | - # Banned under v1. v1-prop-types | lint:v1 | PropTypes are not used @@ -168,7 +169,7 @@ function checkCode(files) { writeFile(dir, rel, content); } const { output } = runAll(dir, ["node scripts/check-v1.mjs --code"]); - return output.split("\n").filter((l) => l.startsWith("src/v1/")); + return output.split("\n").filter((l) => l.startsWith("src/")); } finally { removeTree(dir); } @@ -320,6 +321,8 @@ test("folder contract: every import form is read", () => { afterDoubleSlashInString: 'const u = "a//b"; export { a } from "@v1/services/a";', glob: 'export const a = import.meta.glob("@v1/services/*.ts");', + globArrayLater: + 'export const a = import.meta.glob(["@v1/components/ui/*.tsx", "@v1/services/*.ts"]);', afterTemplateExpression: // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. 'const t = `${"}"}/*`;\nimport { a } from "@v1/services/a";\n/** doc */', @@ -379,3 +382,41 @@ test("v1 imports nothing outside src/v1, in any spelling", () => { ); }); }); + +test("v0 reaches v1 only through @v1, in any form", () => { + const denied = { + "static.jsx": 'import x from "@v1/lib/x";', + "template.jsx": "export const a = () => import(`@v1/plugins/manifest`);", + "glob.jsx": 'export const a = import.meta.glob("@v1/pages/*/index.ts");', + "globArrayLater.jsx": + 'export const a = import.meta.glob(["./*.jsx", "../v1/lib/*.ts"]);', + "require.jsx": 'export const a = require("../v1/lib/x");', + "aliasV0.jsx": 'export const a = () => import("@/v1/lib/x");', + "srcPath.jsx": 'export const a = () => import("src/v1/lib/x");', + "relativeEntry.jsx": 'import "../v1";', + "styles.css": '@import "../v1/styles/tokens.css";', + }; + const allowed = { + "entry.jsx": 'import "@v1";', + "entryTemplate.jsx": "export const a = () => import(`@v1`);", + "v0Only.jsx": 'import x from "../helpers/x";\nexport const y = x;', + }; + const files = {}; + for (const [name, code] of Object.entries({ ...denied, ...allowed })) { + files[`src/pages/${name}`] = `${code}\n`; + } + const reported = checkCode(files); + const v0Lines = reported.filter((l) => l.startsWith("src/pages/")); + for (const name of Object.keys(denied)) { + assert.ok( + v0Lines.some((l) => l.startsWith(`src/pages/${name}:`)), + `${name}: not reported\n${reported.join("\n")}`, + ); + } + for (const name of Object.keys(allowed)) { + assert.ok( + !v0Lines.some((l) => l.startsWith(`src/pages/${name}:`)), + `${name}: reported but allowed\n${reported.join("\n")}`, + ); + } +}); diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-template/src/pages/Bad.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-template/src/pages/Bad.jsx new file mode 100644 index 0000000000..278db55314 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-template/src/pages/Bad.jsx @@ -0,0 +1 @@ +export const loadManifest = () => import(`@v1/plugins/manifest`);