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/.gitignore b/.gitignore index cfd13e388f..4817bafa05 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..6799918af6 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -129,5 +129,73 @@ "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/**", "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/." + } + ] + } + } + }, + "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..3e842480c4 --- /dev/null +++ b/frontend/scripts/check-v1.mjs @@ -0,0 +1,463 @@ +#!/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 Imports: nothing outside src/v1 and the folder import contract + * (which v1 folder may import which). No PropTypes, no antd `ant-` + * 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 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. + * + * 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 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"); + +const ALL = "*"; +/* + * Folder -> folders it may import from, besides itself. `entry` is + * 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 = { + entry: ALL, + app: ALL, + plugins: ALL, + test: ALL, + pages: [ + "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 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)\.(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 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. + */ +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, 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) { + let end; + if (stack.at(-1) === "`") { + 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; + continue; + } else if (src[i] === '"' || src[i] === "'") { + end = stringEnd(src, i); + } else { + 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( + String.raw`\b(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]+?\s+from\s+)?${SPEC}`, + "g", + ), + new RegExp(String.raw`\bimport\s*\(\s*${SPEC}\s*[,)]`, "g"), + new RegExp(String.raw`\brequire\s*\(\s*${SPEC}\s*\)`, "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; + +/* + * 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"; + } + 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; + } + 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; +} + +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() || d.isSymbolicLink()) && !IGNORED.has(d.name)) + .map((d) => + path + .relative(dir, path.join(d.parentPath, d.name)) + .replaceAll(path.sep, "/"), + ) + .toSorted((a, b) => a.localeCompare(b)); +} + +const problems = []; +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`); + process.exit(2); +} + +for (const rel of walk(ROOT)) { + const name = path.posix.basename(rel); + 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."); + } + 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 && 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(/(? 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).`); + 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..61fa459ac3 --- /dev/null +++ b/frontend/src/v1/README.md @@ -0,0 +1,50 @@ +# 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 (UN-4378); 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, and no symlinks. Import with `./` within a folder and `@v1/...` across + folders; `../`, `/src/...` and `src/...` are 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/` and are imported as + `@v1/styles/...`, including from inside `styles/`. + +## Folders and what each may import + +Every folder may import from itself. Test files (`*.test.ts(x)`, +`*.spec.ts(x)`) may also import `test/`. + +| Folder | Holds | May import from | +| --- | --- | --- | +| `index.ts` | Public entry; the version switch (UN-4271) will mount v1 through it | 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 | `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/` | +| `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, 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, 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`, +so `biome ci src/` (CI) and editors report them too. The remaining checks are +`src/v1/tsconfig.json` and `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..48afca1c00 --- /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 (UN-4271) 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..c139d37fbf --- /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 UN-4285 and UN-4286. + */ +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..0a10f9dbdb --- /dev/null +++ b/frontend/src/v1/tsconfig.json @@ -0,0 +1,42 @@ +{ + /* + * 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, + /* Off so hand-written .d.ts under v1 are checked too; with it on, an + * unresolved name in one silently becomes `any`. */ + "skipLibCheck": false, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Only `@v1` and `@v1/*` — deliberately no `@/*`, so an `@/` import + * fails typecheck as well as lint (`../` imports are caught by lint). The + * alias is also declared 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..c530319d89 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -0,0 +1,422 @@ +/* + * Proves the v1 guardrails fail when they should and pass when they should. + * Run by `bun run test:v1` with `node --test`: it spawns the real tools, so it + * is not a vitest test (vitest.config.mjs excludes test-fixtures/). + * + * 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 { 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"), +); + +/* 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())); +} + +const sorted = (items) => items.toSorted((a, b) => a.localeCompare(b)); + +/* + * 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-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 + 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-")); + 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 an + // ignore file at the root. + 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 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("/bin/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}`, + }, + }); + 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, 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/")); + } finally { + removeTree(dir); + } +} + +/* + * 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() && 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 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(sorted(fs.readdirSync(CASES_DIR)), sorted([...listed])); +}); + +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, log } = runAll(dir, run); + if (fails) { + 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, 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 = 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+/)]), +); + +/* 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`; + const imports = pairFile(to); + files[rel] = `${imports.join("\n")}\n`; + const denied = from !== to && !allowed.includes(to); + expected.push( + ...(denied ? imports : []).map( + (_, i) => `${rel}:${i + 1}: ${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(sorted(reported), sorted(expected)); +}); + +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");', + 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 */', + }; + 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")}`, + ); + }); +}); + +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-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..ecddfc6910 --- /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 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 new file mode 100644 index 0000000000..953cb550c6 --- /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 manifestSize = () => pluginManifest.length; 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`); 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/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..07afb25230 --- /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 = Readonly<{ label: string }>; + +export function Tag({ label }: TagProps) { + return {upper(label)}; +} 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.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); 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/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-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-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-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-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..1973bc6c11 --- /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 buttonName = Button.name; 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-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-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-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..4f64a2f1e2 --- /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..acba160b3c --- /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-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-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..f6e2ea9482 --- /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 = `Projects · ${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-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-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-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-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..8024a7c0bb --- /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/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/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..fecc6b40f5 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -184,6 +184,9 @@ export default defineConfig(({ mode }) => { resolve: { alias: { "@": path.resolve(import.meta.dirname, "./src"), + // 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 128c7f042f..25cbe07f68 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 + * 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. */ - exclude: ["**/node_modules/**", "**/dist/**", "**/build/**"], + exclude: [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "test-fixtures/**", + ], }, });