diff --git a/.github/workflows/ci-frontend-lint.yaml b/.github/workflows/ci-frontend-lint.yaml index 1c87375a96..cc65d2c073 100644 --- a/.github/workflows/ci-frontend-lint.yaml +++ b/.github/workflows/ci-frontend-lint.yaml @@ -50,7 +50,7 @@ jobs: uses: actions/checkout@v6 - name: Setup Biome - uses: biomejs/setup-biome@v2 + uses: biomejs/setup-biome@4c91541eaada48f67d7dbd7833600ce162b68f51 # v2.7.1 with: version: "2.3.13" @@ -121,6 +121,31 @@ 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 checks file types, + # placement, symlinks and the folder taxonomy. + - name: Run v1 typecheck + working-directory: frontend + run: bun run typecheck:v1 + + # Biome on src/v1, then scripts/check-v1.mjs: the folder import + # contract, v0 reaching v1 only through `@v1`, PropTypes and `ant-`. + - name: Run v1 lint + working-directory: frontend + run: bun run lint:v1 + + # The guardrail fixtures run under `node --test` (Node 22 APIs). + - name: Setup Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22" + + # v1 unit tests, then the fixtures proving each guard above fails and + # passes when it should. + - name: Run v1 tests + working-directory: frontend + run: bun run test:v1 + # ci-test.yaml runs the same suite as the rig's `frontend` group, but its # tiers skip frontend-only PRs; this job is what gates those. vitest: diff --git a/.gitignore b/.gitignore index cfd13e388f..4b391d4453 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/ @@ -635,6 +639,10 @@ workers/pluggable_worker/* # FE Plugins frontend/src/plugins/* frontend/public/llm-whisperer/ +# v1 cloud plugins are copied in at build time; only the placeholder manifest +# is public. +frontend/src/v1/plugins/* +!frontend/src/v1/plugins/manifest.ts # TODO: Ensure its made generic to abstract subfolder and file names 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..5a2b45318b 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -129,5 +129,97 @@ "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", + "**/*.less", + "**/*.sass", + "**/*.scss", + "**/*.styl", + "**/*.stylus", + "**/*.pcss", + "**/*.postcss", + "**/*.sss", + "!@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/**", + "src/v1", + "src/v1/**", + "/src/v1", + "/src/v1/**", + "./v1", + "./v1/**", + "../**/v1", + "../**/v1/**", + "!@v1" + ], + "message": "v0 may import only the v1 public entry, as '@v1'." + } + ] + } + } + } + } + } + } + ] } diff --git a/frontend/bun.lock b/frontend/bun.lock index 3616692ef3..94f3b30008 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -66,6 +66,7 @@ "zustand": "^4.3.8", }, "devDependencies": { + "@babel/parser": "^7.28.6", "@biomejs/biome": "^2.3.13", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.20", @@ -80,6 +81,8 @@ "autoprefixer": "^10.5.4", "baseline-browser-mapping": "^2.9.19", "happy-dom": "^20.10.2", + "postcss": "^8.5.23", + "postcss-value-parser": "^4.2.0", "tailwindcss": "^4", "typescript": "^7.0.2", "vite": "^7.3.5", diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json index 082b33982c..d2e095ab6b 100644 --- a/frontend/jsconfig.json +++ b/frontend/jsconfig.json @@ -2,9 +2,11 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@v1": ["./src/v1/index.ts"], + "@v1/*": ["./src/v1/*"] } }, "include": ["src/**/*"], - "exclude": ["node_modules", "build"] + "exclude": ["node_modules", "build", "src/v1"] } diff --git a/frontend/package.json b/frontend/package.json index b4ab1e1bd5..d59e29aaec 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": [ @@ -92,6 +95,7 @@ ] }, "devDependencies": { + "@babel/parser": "^7.28.6", "@biomejs/biome": "^2.3.13", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.20", @@ -106,6 +110,8 @@ "autoprefixer": "^10.5.4", "baseline-browser-mapping": "^2.9.19", "happy-dom": "^20.10.2", + "postcss": "^8.5.23", + "postcss-value-parser": "^4.2.0", "tailwindcss": "^4", "typescript": "^7.0.2", "vite": "^7.3.5", diff --git a/frontend/scripts/check-v1.mjs b/frontend/scripts/check-v1.mjs new file mode 100755 index 0000000000..5900ca0c44 --- /dev/null +++ b/frontend/scripts/check-v1.mjs @@ -0,0 +1,965 @@ +#!/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/, no file + * type outside an allowlist, no symlinks, 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`. Every file is read + * except assets, data and docs; one of unknown type is read as + * code, since Vite can load any file (`?worker`). Run by + * `lint:v1`. + * + * With neither flag, both run. It checks the src/ beside this script; the + * fixtures in test-fixtures/v1-boundary/ copy it into each throwaway tree for + * that reason. + * + * Imports are read from syntax trees (@babel/parser for code, PostCSS for + * styles), so comments, strings, regexes and JSX text never hide or fake one: + * static import/export statements, `import x = require()`, `typeof import()` + * types, and the calls Vite resolves: import(), require(), import.meta.glob() + * (with its `base` option) and new URL(..., import.meta.url). A template or + * glob argument is checked by its fixed prefix, and a `..` after that prefix + * is reported. In v1, an argument that is neither (a variable, a + * concatenation, a prefix with no `/`) is reported as unanalysable rather + * than passed, and so is a file that does not parse. v0 is judged by what + * Vite can resolve: a concatenation by the string it starts with, as Vite + * globs it; a bare variable is left alone, since Vite cannot bundle it. + * + * The v0 <-> v1 boundary is also a Biome rule (biome.json `overrides`), so CI + * and editors report the plain forms. 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"; +import { parse } from "@babel/parser"; +import postcss from "postcss"; +import valueParser from "postcss-value-parser"; + +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", + "assets", + "plugins", + ], + "components/ui": ["lib", "styles", "assets"], + "components/patterns": [ + "components/ui", + "lib", + "services", + "stores", + "hooks", + "assets", + ], + shell: [ + "components/ui", + "components/patterns", + "services", + "stores", + "hooks", + "lib", + "assets", + ], + services: ["lib"], + stores: ["lib"], + hooks: ["lib"], + lib: [], + styles: ["lib", "assets"], + // Static files (icons, logos, images); a generated icon map may live here. + assets: [], +}; +const TOP_LEVEL = new Set([ + "app", + "shell", + "services", + "stores", + "hooks", + "lib", + "styles", + "assets", + "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)$/; +// Vite's CSS languages (CSS_LANGS_RE); PostCSS reads them without a plugin +// only when they are plain CSS, and anything else fails to parse. +const STYLE_EXT = /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)$/i; +// Vite's asset types (KNOWN_ASSET_TYPES), and data and docs: Vite resolves +// nothing inside them. +const INERT_EXT = + /\.(apng|bmp|png|jpe?g|jfif|pjpeg|pjp|gif|svg|ico|webp|avif|cur|jxl|mp4|webm|ogg|mp3|wav|flac|aac|opus|mov|m4a|vtt|woff2?|eot|ttf|otf|webmanifest|pdf|txt|json|md)$/i; +const TEST_FILE = /\.(test|spec)\.(ts|tsx|mts|cts)$/; + +/* How a file is read: "code", "style", "inert" (nothing in it is resolved + * as an import) or "unknown", which is read as code. */ +function fileKind(name) { + if (CODE_EXT.test(name) || JS_EXT.test(name)) { + return "code"; + } + if (STYLE_EXT.test(name)) { + return "style"; + } + return INERT_EXT.test(name) || name === ".gitkeep" ? "inert" : "unknown"; +} +// Specifiers that leave src/v1 by name: root-absolute, the v0 alias, src/. +const OUTSIDE = /^(\/|@\/|src\/)/; +// `./...` and `../...`, resolved against the importing file. +const RELATIVE = /^\.\.?(\/|$)/; +// The v1 public entry, in the spellings that resolve to src/v1/index.ts. +const ENTRY_SPEC = /^@v1(\/index(\.ts)?)?$/; +// antd packages, which v1 styles may not pull in either. +const ANTD = /^(antd|@ant-design)(\/|$)/; + +/* + * 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); +} + +/* + * Parses a JS or TS file into a Babel AST: { ast }, or { error, line } when + * it does not parse. `.ts` files get no JSX, so `x` casts still parse. + */ +function parseCode(src, name) { + const isTs = /\.(ts|tsx|mts|cts)$/.test(name); + // `dts` allows what only a declaration file may hold (`const x: T;`). + const dts = /\.d\.[mc]?ts$/.test(name); + const plugins = isTs ? [["typescript", { dts }]] : []; + if (!isTs || name.endsWith(".tsx")) { + plugins.push("jsx"); + } + try { + return { ast: parse(src, { sourceType: "module", plugins }) }; + } catch (err) { + return { error: err.message, line: err.loc?.line ?? 0 }; + } +} + +// Positions and attached comments, not child nodes. +const NOT_CHILDREN = new Set([ + "loc", + "start", + "end", + "extra", + "leadingComments", + "trailingComments", + "innerComments", +]); + +/* The child nodes of node. */ +function* children(node) { + for (const [key, value] of Object.entries(node)) { + if (NOT_CHILDREN.has(key) || value === null || typeof value !== "object") { + continue; + } + for (const child of Array.isArray(value) ? value : [value]) { + if (typeof child?.type === "string") { + yield child; + } + } + } +} + +/* Every node under root. */ +function* nodes(root) { + const stack = [root]; + while (stack.length > 0) { + const node = stack.pop(); + yield node; + stack.push(...children(node)); + } +} + +/* Whether node is `import.meta.`, type-only wrappers aside + * (`import.meta.url as string`, `(import.meta.glob as any)`). */ +function isImportMeta(node, prop) { + const member = unwrap(node); + const object = unwrap(member?.object); + return ( + member?.type === "MemberExpression" && + !member.computed && + object?.type === "MetaProperty" && + object.meta.name === "import" && + member.property.name === prop + ); +} + +// Stands for a `${}` hole while a template is read as one string. +const HOLE = "\0"; + +/* + * The import arguments node stands for (a conditional stands for both + * branches), each as { kind, spec }: "literal" for a plain string; "prefix" + * for a template with `${}` or a glob with a wildcard, spec being the fixed + * part before the first one; "opaque" for anything else. Vite globs a + * concatenation (`"./a/" + n`, `"./a/".concat(n)`) on the string it starts + * with, so an opaque argument keeps that as `start`. `climbsLater` marks a + * `..` segment after the prefix, which can leave the folder the prefix names. + */ +function argsOf(node, isGlob) { + const arg = unwrap(node); + if (arg?.type === "ConditionalExpression") { + return [ + ...argsOf(arg.consequent, isGlob), + ...argsOf(arg.alternate, isGlob), + ]; + } + const text = fixedText(arg); + if (text === undefined) { + return [{ kind: "opaque", start: concatStart(arg) }]; + } + const at = text.search(isGlob ? /[*?{[\0]/ : /\0/); + if (at === -1) { + return [{ kind: "literal", spec: text }]; + } + const climbsLater = text + .slice(at) + .split("/") + .some((seg) => seg.replace(/[*?\0]/g, "") === ".."); + return [{ kind: "prefix", spec: text.slice(0, at), climbsLater }]; +} + +/* A string or template as text, holes as HOLE; undefined for anything else. + * `cooked` is null only in tagged templates, which are not import calls. */ +function fixedText(arg) { + if (arg?.type === "StringLiteral") { + return arg.value; + } + if (arg?.type === "TemplateLiteral") { + return arg.quasis.map((q) => q.value.cooked).join(HOLE); + } + return undefined; +} + +/* The fixed text a `+` chain or `.concat()` call starts with, if any. */ +function concatStart(node) { + const arg = unwrap(node); + let first; + if (arg?.type === "BinaryExpression" && arg.operator === "+") { + first = arg.left; + } else if ( + arg?.type === "CallExpression" && + arg.callee.type === "MemberExpression" && + keyName(arg.callee.property, arg.callee.computed) === "concat" + ) { + first = arg.callee.object; + } else { + return undefined; + } + const text = fixedText(unwrap(first)); + return text === undefined ? concatStart(first) : text.split(HOLE)[0]; +} + +// Type-only wrappers: `x as const`, `x satisfies T`, `x`, `x!`. +// Parentheses leave no node of their own. +const WRAPPERS = new Set([ + "TSAsExpression", + "TSSatisfiesExpression", + "TSTypeAssertion", + "TSNonNullExpression", +]); + +/* The expression inside any type-only wrappers. */ +function unwrap(node) { + let inner = node; + while (WRAPPERS.has(inner?.type)) { + inner = inner.expression; + } + return inner; +} + +/* Every import in the AST, as { kind, spec, line }, in source order. */ +function importSpecs(ast) { + const specs = []; + const push = (args, at) => { + for (const arg of args) { + specs.push({ ...arg, line: at.loc.start.line, index: at.start }); + } + }; + const add = (arg, at) => push(argsOf(arg, false), at); + for (const node of nodes(ast.program)) { + switch (node.type) { + case "ImportDeclaration": + case "ExportAllDeclaration": + case "ExportNamedDeclaration": + if (node.source) { + add(node.source, node); + } + break; + case "TSImportEqualsDeclaration": + if (node.moduleReference.type === "TSExternalModuleReference") { + add(node.moduleReference.expression, node); + } + break; + case "TSImportType": + add(node.argument.literal ?? node.argument, node); + break; + case "ImportExpression": + add(node.source, node); + break; + case "CallExpression": + callSpecs(node, add, push); + break; + case "NewExpression": + // Only `new URL(x, import.meta.url)` is a module reference. + if ( + isUrlCallee(node.callee) && + isImportMeta(node.arguments[1], "url") + ) { + add(node.arguments[0], node); + } + break; + default: + break; + } + } + return specs.toSorted((a, b) => a.index - b.index); +} + +function isUrlCallee(callee) { + const inner = unwrap(callee); + return inner?.type === "Identifier" && inner.name === "URL"; +} + +/* import(), require() and import.meta.glob(); a method that happens to be + * named `import` or `require` (`api.import(f)`) is not one of them. */ +function callSpecs(node, add, push) { + const callee = unwrap(node.callee); + const [first, options] = node.arguments; + if ( + callee.type === "Import" || + (callee.type === "Identifier" && callee.name === "require") + ) { + add(first, node); + } else if (isImportMeta(callee, "glob")) { + const list = unwrap(first); + const patterns = list?.type === "ArrayExpression" ? list.elements : [first]; + const base = globBase(options); + for (const pattern of patterns) { + const literal = unwrap(pattern); + // `!` patterns exclude files rather than import them. + if ( + !(literal?.type === "StringLiteral" && literal.value.startsWith("!")) + ) { + const args = argsOf(pattern, true).map((arg) => rebase(arg, base)); + push(args, pattern ?? node); + } + } + } +} + +/* + * The `base` option of an import.meta.glob() call, which Vite resolves + * relative patterns against: undefined when there is none, the string, or + * null when it cannot be read (then every pattern is opaque). + */ +function globBase(options) { + const opts = unwrap(options); + if (opts === undefined) { + return undefined; + } + if (opts.type !== "ObjectExpression") { + return null; + } + let base; + for (const prop of opts.properties) { + // A spread, a method or a computed key might be `base`. + const key = + prop.type === "ObjectProperty" + ? keyName(prop.key, prop.computed) + : undefined; + if (key === undefined) { + return null; + } + if (key === "base") { + base = unwrap(prop.value); + } + } + if (base === undefined) { + return undefined; + } + return base.type === "StringLiteral" ? base.value : null; +} + +/* A glob argument with `base` applied; it applies to relative patterns only. */ +function rebase(arg, base) { + if (base === null) { + return { kind: "opaque" }; + } + if (base === undefined || base === "" || arg.kind === "opaque") { + return arg; + } + if (!RELATIVE.test(arg.spec)) { + return arg; + } + let spec = path.posix.join(base, arg.spec); + if (arg.spec.endsWith("/") && !spec.endsWith("/")) { + spec += "/"; + } + return { ...arg, spec: /^[./@]/.test(spec) ? spec : `./${spec}` }; +} + +/* The name a property key spells, or undefined when it is computed. */ +function keyName(key, computed) { + if (!computed && key.type === "Identifier") { + return key.name; + } + return key.type === "StringLiteral" ? key.value : undefined; +} + +function isPropTypes(node) { + switch (node.type) { + case "MemberExpression": + return keyName(node.property, node.computed) === "propTypes"; + case "ObjectProperty": + case "ClassProperty": + return keyName(node.key, node.computed) === "propTypes"; + case "VariableDeclarator": + return node.id.type === "Identifier" && node.id.name === "propTypes"; + default: + return false; + } +} + +// An antd class name, not the tail of one like `variant-x`. +const ANT_CLASS = /(? spec.replace(/\\(\W)/g, "$1"); + +/* The target of an import at-rule: its first string, word or url(). */ +function cssImportTarget(params) { + const first = valueParser(params).nodes.find((n) => n.type !== "space"); + if (first?.type === "function" && first.value.toLowerCase() === "url") { + return first.nodes.find((n) => n.type !== "space")?.value; + } + return first?.type === "string" || first?.type === "word" + ? first.value + : undefined; +} + +/* The asset references in a CSS value, as Vite reads them: each url(), and + * each string inside image-set() or -webkit-image-set(). */ +function cssValueTargets(value) { + const targets = []; + valueParser(value).walk((node) => { + if (node.type !== "function") { + return; + } + const name = node.value.toLowerCase(); + if (name === "url") { + const arg = node.nodes.find((n) => n.type !== "space"); + if (arg) { + targets.push(arg.value); + } + } else if (name.endsWith("image-set")) { + for (const arg of node.nodes) { + if (arg.type === "string") { + targets.push(arg.value); + } + } + } + }); + return targets; +} + +/* The text of a PostCSS node that can hold a URL or a class name. */ +function cssText(node) { + if (node.type === "rule") { + return node.selector; + } + if (node.type === "atrule") { + return node.params; + } + return node.type === "decl" ? node.value : ""; +} + +/* The import targets in one PostCSS node; undefined for an import at-rule + * whose target cannot be read. */ +function cssNodeTargets(node) { + if (node.type === "atrule" && CSS_IMPORT_RULES.has(node.name.toLowerCase())) { + return [cssImportTarget(node.params)]; + } + return node.type === "rule" ? [] : cssValueTargets(cssText(node)); +} + +/* + * A stylesheet read with PostCSS, so comments and strings are told apart: + * { imports: [{ spec, line }], antLines }, or { error, line } when it does + * not parse. At-rule names are case-insensitive, and a string may follow one + * with no space (`@import"x"`). `spec` is undefined for an import at-rule + * whose target cannot be read. + */ +function cssScan(src) { + let root; + try { + root = postcss.parse(src); + } catch (err) { + return { error: err.reason ?? err.message, line: err.line ?? 0 }; + } + const imports = []; + const antLines = []; + root.walk((node) => { + const line = node.source?.start?.line ?? 0; + for (const target of cssNodeTargets(node)) { + imports.push({ spec: target && cssUnescape(target), line }); + } + if (cssText(node).match(ANT_CLASS)) { + antLines.push(line); + } + }); + return { imports, antLines }; +} + +/* A `@v1/` or relative specifier as a path relative to src/v1, or + * "outside" when it leaves src/v1. */ +function resolveV1(spec, fileRel) { + const rel = spec.startsWith("@v1/") + ? path.posix.normalize(spec.slice(4)) + : path.posix.join(path.posix.dirname(fileRel), spec); + return rel === ".." || rel.startsWith("../") ? "outside" : rel; +} + +const climbs = (spec) => spec.split("/").includes(".."); + +/* + * 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 (ENTRY_SPEC.test(spec)) { + return "entry"; + } + if (!spec.startsWith("@v1/") && !RELATIVE.test(spec)) { + return undefined; + } + const rel = resolveV1(spec, fileRel); + if (rel === "outside") { + return rel; + } + // 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); +} + +/* + * Classifies a template's fixed prefix like targetFolder, by the directory it + * fixes (up to its last "/"). "any" when that leaves the v1 folder open, as in + * `@v1/pages/${name}`; only folders that may import everything may do that. + */ +function prefixFolder(prefix, fileRel) { + if (OUTSIDE.test(prefix)) { + return "outside"; + } + const dir = prefix.slice(0, prefix.lastIndexOf("/") + 1); + if (!dir.startsWith("@v1/") && !RELATIVE.test(dir)) { + return undefined; + } + const rel = resolveV1(dir, fileRel); + if (rel === "outside") { + return rel; + } + const isRoot = rel === "" || rel === "." || rel === "./"; + return (isRoot ? null : folderOf(rel, true)) ?? "any"; +} + +/* The problem with one v1 import, or null. A prefix with no "/" fixes no + * directory at all (`.${n}` may become `../x`, `@v1${n}` any folder). */ +function v1ImportProblem(folder, rel, arg, isTestFile) { + if ( + arg.kind === "opaque" || + (arg.kind === "prefix" && !arg.spec.includes("/")) + ) { + return "unanalysable import; use a string literal, or a template whose fixed prefix names the folder."; + } + const { spec } = arg; + if (arg.climbsLater) { + return `'${spec}...' has '..' after a wildcard or \`\${}\`, so it can leave the folder it names; import with './' within a folder and '@v1/...' across folders.`; + } + const to = + arg.kind === "prefix" ? prefixFolder(spec, rel) : targetFolder(spec, rel); + if (to === undefined) { + return null; + } + if (to === "outside") { + return `'${spec}' is outside src/v1; v1 imports nothing outside it.`; + } + if (to !== null && to !== "any" && !mayImport(folder, to, isTestFile)) { + return `${folder}/ may not import from ${to}/ ('${spec}'); see the folder contract in src/v1/README.md.`; + } + // A `..` that stays inside v1 is still not how v1 imports. + if (climbs(spec)) { + return `'${spec}' climbs with '../'; import with './' within a folder and '@v1/...' across folders.`; + } + if (to === null) { + return `'${spec}' is outside the v1 folder taxonomy.`; + } + if (to === "any" && CONTRACT[contractKey(folder)] !== ALL) { + return `${folder}/ may not import a computed v1 path ('${spec}...'); only app/, plugins/, test/ and the entry may.`; + } + return null; +} + +/* The problem with one import in a v1 style file, or null. */ +function cssImportProblem(spec, rel, folder) { + if (spec === undefined) { + return "unanalysable import; use a string or url() target."; + } + if (ANTD.test(spec)) { + return "antd is not used under src/v1."; + } + if (targetFolder(spec, rel) === "outside") { + return `'${spec}' is outside src/v1; v1 styles import nothing outside it.`; + } + return v1ImportProblem(folder, rel, { kind: "literal", spec }, false); +} + +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 listed rather than + * followed; callers decide what to do with them. */ +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}`); +}; + +function checkV1Css(rel, folder) { + const scan = cssScan(fs.readFileSync(path.join(ROOT, rel), "utf8")); + if (scan.error) { + report(rel, scan.line, `could not parse (${scan.error}).`); + return; + } + for (const { spec, line } of scan.imports) { + const problem = cssImportProblem(spec, rel, folder); + if (problem) { + report(rel, line, problem); + } + } + for (const line of scan.antLines) { + report(rel, line, "No antd 'ant-' class names under v1."); + } +} + +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; + } + + const kind = fileKind(name); + if (runFiles) { + if (JS_EXT.test(name)) { + report(rel, 0, "v1 is TypeScript only; use .ts or .tsx."); + } + if (kind === "style" && folder !== "styles") { + report(rel, 0, "No component style files; only styles/ holds CSS."); + } + if (kind === "unknown") { + report( + rel, + 0, + "Unknown file type under v1; it holds TypeScript, styles (in styles/), Vite assets, JSON and Markdown.", + ); + } + if ( + isRootFile + ? !ROOT_FILES.has(name) + : folder === null && name !== ".gitkeep" + ) { + report(rel, 0, "Outside the v1 folder taxonomy (see src/v1/README.md)."); + } + } + + // Style files and unknown types are read wherever they are, so --code + // alone still checks what --files would reject. + if (!runCode || folder === null || kind === "inert") { + continue; + } + if (kind === "style") { + checkV1Css(rel, folder); + continue; + } + const src = fs.readFileSync(path.join(ROOT, rel), "utf8"); + const parsed = parseCode(src, name); + if (parsed.error) { + report(rel, parsed.line, `could not parse (${parsed.error}).`); + continue; + } + const isTestFile = TEST_FILE.test(name); + + for (const arg of importSpecs(parsed.ast)) { + const problem = v1ImportProblem(folder, rel, arg, isTestFile); + if (problem) { + report(rel, arg.line, problem); + } + } + for (const { index, msg } of bannedUses(parsed.ast, src)) { + report(rel, lineAt(src, index), msg); + } +} + +/* + * The v0 -> v1 half: v0 (everything in src/ outside v1, including the cloud + * src/plugins/ overlay) reaches v1 only through the `@v1` entry. + */ +/* A v0 specifier as a path from the project root, or undefined for a + * package. fileRel is relative to src/. */ +function v0Target(spec, fileRel) { + if (spec.startsWith("@v1")) { + return `src/v1${spec.slice(3)}`; + } + if (spec.startsWith("@/")) { + return `src/${spec.slice(2)}`; + } + if (spec.startsWith("/")) { + return spec.slice(1); + } + if (spec.startsWith("src/")) { + return spec; + } + if (spec.startsWith(".")) { + return path.posix.join("src", path.posix.dirname(fileRel), spec); + } + return undefined; +} + +/* + * Whether a v0 import reaches into v1 other than through the `@v1` entry. + * A template, glob or concatenation can still add to its fixed start, so it + * reaches v1 when that is inside v1 or could grow into it (`@/v1${n}`, + * `../**`), or when a `..` follows it. + */ +function intoV1(arg, fileRel) { + const spec = arg.kind === "opaque" ? arg.start : arg.spec; + if (spec === undefined) { + return false; + } + if (arg.kind === "literal" && ENTRY_SPEC.test(spec)) { + return false; + } + const target = v0Target(spec, fileRel); + if (target === undefined) { + return false; + } + const norm = path.posix.normalize(target).replace(/^\.\/?$/, ""); + if (norm === "src/v1" || norm.startsWith("src/v1/")) { + return true; + } + return ( + arg.kind !== "literal" && + ("src/v1/".startsWith(norm) || arg.climbsLater === true) + ); +} + +function v0Specs(rel, raw) { + if (STYLE_EXT.test(rel)) { + const scan = cssScan(raw); + return scan.error + ? { error: scan } + : { specs: scan.imports.map((i) => ({ kind: "literal", ...i })) }; + } + const parsed = parseCode(raw, rel); + return parsed.error ? { error: parsed } : { specs: importSpecs(parsed.ast) }; +} + +/* The real path of a symlink, or null when it is broken. */ +function realPath(file) { + try { + return fs.realpathSync(file); + } catch { + return null; + } +} + +/* Whether to scan a v0 symlink; one into v1 is reported instead. A file + * link is scanned like the file; a folder link is not followed. */ +function scanV0Link(rel, full, v1Real) { + const real = realPath(full); + if (real === v1Real || real?.startsWith(`${v1Real}${path.sep}`)) { + problems.push( + `src/${rel}: links into src/v1; v0 may reach v1 only through the '@v1' entry.`, + ); + return false; + } + return real !== null && fs.statSync(real).isFile(); +} + +function checkV0File(rel, full) { + const { specs, error } = v0Specs(rel, fs.readFileSync(full, "utf8")); + if (error) { + problems.push( + `src/${rel}:${error.line}: could not parse (${error.error}).`, + ); + return; + } + for (const arg of specs) { + if (intoV1(arg, rel)) { + const spec = + arg.kind === "literal" ? arg.spec : `${arg.spec ?? arg.start}...`; + problems.push( + `src/${rel}:${arg.line}: '${spec}' reaches into src/v1; v0 may import only the v1 public entry, as '@v1'.`, + ); + } + } +} + +function checkV0() { + const v1Real = fs.realpathSync(ROOT); + for (const rel of walk(SRC)) { + const full = path.join(SRC, rel); + const scanned = fileKind(path.posix.basename(rel)) !== "inert"; + if (rel.startsWith("v1/")) { + continue; + } + if (fs.lstatSync(full).isSymbolicLink() && !scanV0Link(rel, full, v1Real)) { + continue; + } + if (scanned) { + checkV0File(rel, full); + } + } +} + +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..4fa2aaa056 --- /dev/null +++ b/frontend/src/v1/README.md @@ -0,0 +1,71 @@ +# 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). The +rules below are enforced by tooling unless marked as convention, and so is the +"May import from" column, for code and for CSS in `styles/`. The "Holds" column +is convention. + +## Rules + +- **TypeScript only.** `.ts` / `.tsx`, `strict`, unused locals and parameters + are errors, no explicit `any`. Besides code, v1 holds only styles (in + `styles/`), Vite asset types, JSON and Markdown; any other file type is + rejected, since Vite can load any file (`?worker`). +- **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`). +- **Imports must be readable.** Use a string literal, or a template whose fixed + prefix names the folder (`` `@v1/pages/${name}/index.tsx` ``, allowed only in + folders that may import everything). In v1, an import of a variable, a + concatenation or a template whose prefix has no `/` is rejected, and so is a + `..` after a wildcard or `${}`, and a file that does not parse. v0 is judged + by what Vite can resolve: a template, glob or concatenation by the fixed + text it starts with; a bare variable is left alone, since Vite cannot bundle + it. +- **No PropTypes, no component style files, no antd** (package, shim or + `ant-` class). Style files are any CSS language Vite knows (`.css`, `.scss`, + `.pcss`, …); CSS asset references include `image-set()` strings. Global v1 styles live in `styles/` and are imported as + `@v1/styles/...`, including from inside `styles/`. +- **Static assets live in `assets/`**, imported as `@v1/assets/...` (e.g. + `@v1/assets/logo.svg?react`). v1 never uses v0's `src/assets/` (enforced); + where an asset file sits under v1 is convention, not checked. + +## 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/`, `assets/` | +| `components/patterns/` | Composites built from core components | `components/ui/`, `lib/`, `services/`, `stores/`, `hooks/`, `assets/` | +| `shell/` | Top bar, rail, context bar, tab bar | `components/ui/`, `components/patterns/`, `services/`, `stores/`, `hooks/`, `lib/`, `assets/` | +| `pages//` | One folder per target page | `components/ui/`, `components/patterns/`, `shell/`, `services/`, `stores/`, `hooks/`, `lib/`, `styles/`, `assets/`, `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/`, `assets/` | +| `assets/` | Static files: icons, logos, images (and a generated icon map) | — | +| `plugins/` | Cloud overlay target; only a placeholder in the public repository | everything | +| `test/` | Render helpers, fixtures, network mocks | everything | + +## Scripts + +| Script | Checks | +| --- | --- | +| `bun run typecheck:v1` | TypeScript-only files, styles only in `styles/`, allowed file types only, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` | +| `bun run lint:v1` | Biome on `src/v1/`, then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks | +| `bun run test:v1` | v1 unit tests, then the guardrail fixtures in `test-fixtures/v1-boundary/` | + +Biome (`biome.json`) also reports the banned imports and the plain spellings of +the v0 ↔ v1 boundary, so `biome ci` and editors flag them too: `@/`, `../`, +`/src/` and `src/` in v1; `@v1/...`, `@/v1`, `src/v1`, `/src/v1` and relative +`./v1` / `../**/v1` in v0. The relative v0 patterns match any folder named +`v1`, so no v0 folder may take that name. Everything else (the folder +contract, templates, globs, `new URL`, CSS, symlinks) is checked only by +`scripts/check-v1.mjs`, and types by `src/v1/tsconfig.json`. 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/assets/.gitkeep b/frontend/src/v1/assets/.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..89bfe6e386 --- /dev/null +++ b/frontend/src/v1/tsconfig.json @@ -0,0 +1,44 @@ +{ + /* + * 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, + /* On, as in tsconfig.json. Off also type-checks third-party typings, and + * those conflict: @types/chai 5.2.3 and vitest 3.2.6 both declare + * `containSubset`, so any v1 test importing vitest failed. The cost is + * that hand-written .d.ts files under v1 are not checked either. */ + "skipLibCheck": true, + "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..705ecd2c9f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/boundary.fixtures.mjs @@ -0,0 +1,820 @@ +/* + * 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, tsconfig.json, vite-env.d.ts, scripts/, the real src/v1 + * scaffold, a node_modules symlink and a .gitignore), 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 for plain spellings, check-v1 for all. + 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-relative | lint:v1 | 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/ | - + # A v1-looking path that is not src/v1 (a package subpath, a v0 folder). + v0-imports-v1-lookalike | biome lint src/ + lint:v1 | - + # v0 typecheck follows the entry into v1, where @v1/... must resolve. + v0-tsx-imports-entry | typecheck + typecheck:v1 + lint:v1 | - + # 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-antd-icons | 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-styles-import-antd | lint:v1 | antd is not used under src/v1 + v1-styles-ant-after-url | lint:v1 | No antd 'ant-' class names + v1-unanalysable-import | lint:v1 | unanalysable import + v1-component-css-import | lint:v1 | No component .css under src/v1 + v1-component-css-file | typecheck:v1 | No component style files + # Every CSS language Vite knows, not only .css. + v1-component-pcss-file | typecheck:v1 | No component style files + v1-component-pcss-import | lint:v1 | No component .css under src/v1 + # A file type outside the allowlist (Vite can load it with ?worker). + v1-unknown-file-type | typecheck:v1 | Unknown file type under v1 + 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 + # No @/ path in src/v1/tsconfig.json, so tsc rejects the v0 alias too. + v1-tsc-v0-alias | typecheck:v1 | TS2307 + # 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", + "tsconfig.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. + */ +/* The files among `files` (relative to frontend/) that git ignores. */ +function gitIgnored(files) { + 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 0: some file is ignored; 1: none is; anything else is an error. + assert.ok(result.status === 0 || result.status === 1, result.stderr); + return result.stdout.split("\n").filter(Boolean); +} + +// Where the cloud build copies its v1 plugins; git-ignored on purpose. +const CLOUD_V1_PLUGIN = /^src\/v1\/plugins\/[^/]+\//; + +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 local copy of the cloud plugins, if any, is ignored by design. + .filter((rel) => !CLOUD_V1_PLUGIN.test(rel)), + ); + // 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"); + assert.deepEqual(gitIgnored(files), []); +}); + +test("cloud v1 plugins are git-ignored; the public manifest is not", () => { + const cloud = [ + "src/v1/plugins/billing/index.ts", + "src/v1/plugins/billing/lib/x.ts", + "src/v1/plugins/stray.ts", + ]; + const ignored = gitIgnored([...cloud, "src/v1/plugins/manifest.ts"]); + assert.deepEqual(sorted(ignored), sorted(cloud)); +}); + +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", + assets: "assets", + 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 assets plugins + components/ui | lib styles assets + components/patterns | components/ui lib services stores hooks assets + shell | components/ui components/patterns services stores hooks lib assets + services | lib + stores | lib + hooks | lib + lib | + styles | lib assets + assets | + `).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)); +}); + +/* Reported lines for one generated file. */ +const linesFor = (reported, file) => + reported.filter((l) => l.startsWith(`${file}:`)); + +test("folder contract: every import form is read", () => { + // Each file breaks the contract exactly 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`);", + templatePrefix: + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n: string) => import(`@v1/services/${n}`);", + require: 'export const a = require("@v1/services/a");', + newUrl: 'export const a = new URL("@v1/services/a.ts", import.meta.url);', + 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 */', + afterSlashStarInRegex: + 'export const strip = (p: string) => p.replace(/\\/*$/, "");\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");', + // An apostrophe in JSX text is not a string opener. + afterJsxContraction: + 'export const A = () => It\'s ;', + afterJsxBraceApostrophe: + 'export const B = (n: string) =>

{n}\'s import("@v1/services/a")}>x

;', + 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 */', + // JSX text and attributes that a hand-written tokenizer misread (PR #2380). + afterJsxDivisionText: + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + 'export const P = (p: { d: number; t: number; x: string }) =>

{p.d}/{p.t} {String(`progress/${p.x}`)}

;\nexport const later = () => import("@v1/services/a");', + afterJsxSlashStarText: + 'export const Q = () =>

files/*.ts src/*

;\nexport const later = () => import("@v1/services/a");', + afterJsxBacktickText: + 'export const R = () =>

Press ` to open

;\nexport const later = () => import("@v1/services/a");', + afterJsxSelfClosingTag: + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + 'export const A = (p: { x: string; id: string }) => (\n
```` b
\n);\nexport const later = () => import("@v1/services/a");', + afterMultilineAttribute: + 'export const B = () => (\n \n);', + divisionAfterFrom: + 'export const r = async (range: { from: number }) => range.from / (await import("@v1/services/a")).rate;', + escapedSpecifier: 'import { a } from "@v1\\/services/a";\nexport { a };', + stringNamedBinding: + 'import { "a-b" as c } from "@v1/services/a";\nexport { c };', + typeofImport: 'export type A = typeof import("@v1/services/a");', + importEquals: 'import a = require("@v1/services/a");\nexport { a };', + // TypeScript wrappers around the specifier (Greptile, PR #2380). + asConst: 'export const a = () => import("@v1/services/a" as const);', + satisfies: + 'export const a = () => import(("@v1/services/a" satisfies string));', + // Wrappers around the parts that mark an import (vishnuszipstack, #2380). + newUrlMetaAs: + 'export const a = new URL("@v1/services/a.ts", import.meta.url as string);', + globCalleeAs: + 'export const a = (import.meta.glob as never as (p: string) => unknown)("@v1/services/*.ts");', + requireCalleeAs: + 'export const a = (require as (s: string) => unknown)("@v1/services/a");', + newUrlCalleeAs: + 'export const a = new (URL as typeof URL)("@v1/services/a.ts", import.meta.url);', + // A conditional stands for both branches; lib/ is allowed, services/ not. + conditional: + 'export const a = (c: boolean) => import(c ? "@v1/services/a" : "@v1/lib/x");', + // Vite resolves relative glob patterns against `base`. + globBase: + 'export const a = import.meta.glob("./*.ts", { base: "@v1/services" });', + }; + // None of these may be reported: comments, JSX, look-alike class names. + const quiet = { + "commentedOut.ts": + '// import "@v1/services/a";\n/* import("@/helpers/x"); */\nexport const v = 1;', + "lookalikeClasses.ts": 'export const c = "variant-outline tenant-id";', + // Example source text in strings, templates and regexes is not an import. + "importsInStrings.ts": + 'export const a = "import(name)";\nexport const b = \'import x from "@/helpers/x"\';\nexport const c = `require(n) or import.meta.glob(x)`;\nexport const d = /import\\(x\\)/;', + "jsxQuotes.tsx": + "export const C = () =>

Don't 'quote' me

;", + "jsx.tsx": + 'export const T = () =>
a / b
;\nexport const n = 2 / 1;', + "jsxText.tsx": + "export const J = (p: { d: number; t: number }) =>

{p.d}/{p.t} files/*.ts src/* Press ` to open

;", + "importProse.tsx": + "export const P = () =>

You can import (or export) projects.

;", + // Methods named import/require are not module loads. + "memberCalls.ts": + "export const f = (api: { import(f: string): void; require(f: string): void }, s: string) => {\n api.import(s);\n api.require(s);\n};\nexport class X {\n import(f: string) {\n return f;\n }\n}", + "packageImport.ts": + 'import { useState } from "react";\nexport const u = useState;', + // Syntax only a declaration file may hold. + "declarations.d.ts": + "export const version: string;\nexport function f(a: number): void;", + // `base` applies to relative patterns only. + "globBaseAlias.ts": + 'export const a = import.meta.glob("@v1/lib/*.ts", { base: "../../services" });', + }; + const files = {}; + for (const [form, code] of Object.entries(forms)) { + files[`src/v1/components/ui/${form}.tsx`] = `${code}\n`; + } + for (const [name, code] of Object.entries(quiet)) { + files[`src/v1/components/ui/${name}`] = `${code}\n`; + } + const reported = checkCode(files); + for (const form of Object.keys(forms)) { + const lines = linesFor(reported, `src/v1/components/ui/${form}.tsx`); + assert.equal(lines.length, 1, `${form}:\n${reported.join("\n")}`); + assert.ok( + lines[0].includes("components/ui/ may not import from services/"), + `${form}: ${lines[0]}`, + ); + } + for (const name of Object.keys(quiet)) { + assert.deepEqual(linesFor(reported, `src/v1/components/ui/${name}`), []); + } +}); + +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); + } +}); + +/* Puts each import in its own src/v1/lib/ file and asserts each file is + * reported exactly once, with `message`. */ +function expectEachReported(stem, imports, message) { + const files = {}; + imports.forEach((code, i) => { + files[`src/v1/lib/${stem}-${i}.ts`] = + `export const a = (name: string) => ${code};\n`; + }); + const reported = checkCode(files); + imports.forEach((code, i) => { + const lines = linesFor(reported, `src/v1/lib/${stem}-${i}.ts`); + assert.equal(lines.length, 1, `${code}:\n${reported.join("\n")}`); + assert.ok(lines[0].includes(message), `${code}: ${lines[0]}`); + }); +} + +test("v1 imports nothing outside src/v1, in any spelling", () => { + const imports = [ + "import(`@/helpers/x`)", + "import(`../../helpers/x`)", + "import(`../..`)", + "import(`./../../helpers/x`)", + "import(`/src/assets/logo.svg`)", + "import(`src/helpers/x`)", + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "import(`../../helpers/${name}`)", + 'new URL("../../assets/logo.svg", import.meta.url)', + 'new URL("../../assets/logo.svg", import.meta.url as string)', + 'import.meta.glob("./*.js", { base: "../../components" })', + 'import.meta.glob("./*.js", { base: "/src/helpers" })', + ]; + expectEachReported("outside", imports, "is outside src/v1"); +}); + +test("v1: a '../' that stays inside v1 is reported as such", () => { + const imports = [ + 'import("../lib/x")', + "import(`..`)", + 'import("../helpers/x")', + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "import(`../lib/${name}`)", + ]; + expectEachReported("climbs", imports, "climbs with '../'"); +}); + +test("v1: a '..' after a wildcard or template hole is reported", () => { + const imports = [ + 'import.meta.glob("./*/../../../helpers/*.js")', + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "import(`./${name}/../../../helpers/x.js`)", + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "import(`@v1/lib/${name}..`)", + ]; + expectEachReported("later", imports, "has '..' after a wildcard"); +}); + +test("v1: an import the guard cannot read fails closed", () => { + const files = { + "src/v1/lib/variable.ts": "export const a = (n: string) => import(n);\n", + "src/v1/lib/fullyComputed.ts": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n: string) => import(`${n}`);\n", + "src/v1/components/ui/computedPage.ts": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n: string) => import(`@v1/pages/${n}/index.tsx`);\n", + // The lazy route registry: app/ may import any v1 folder. + "src/v1/app/routes.ts": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n: string) => import(`@v1/pages/${n}/index.tsx`);\n", + // A non-module `new URL` is not an import. + "src/v1/lib/link.ts": + 'export const u = (p: string) => new URL(p, "https://example.com");\n', + "src/v1/lib/concatenation.ts": + 'export const a = (x: string) => import("./" + x);\n', + "src/v1/lib/concatenatedLiterals.ts": + 'export const a = () => import("./" + "../../services/a");\n', + // A prefix with no "/" fixes no folder: `.${n}` may become `../x`. + "src/v1/lib/dotPrefix.ts": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n: string) => import(`.${n}`);\n", + "src/v1/lib/aliasPrefix.ts": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (p: string) => import(`@v1${p}`);\n", + "src/v1/lib/urlVariable.ts": + "export const a = (p: string) => new URL(p, import.meta.url);\n", + "src/v1/components/ui/globAnyFolder.ts": + 'export const a = import.meta.glob("@v1/*/a.ts");\n', + "src/v1/lib/broken.ts": "export const = 1;\n", + // A `base` that is not a string literal could point anywhere. + "src/v1/lib/globBaseVariable.ts": + 'export const a = (b: string) => import.meta.glob("./*.ts", { base: b });\n', + "src/v1/lib/globOptionsSpread.ts": + 'const o = { base: "../../x" };\nexport const a = import.meta.glob("./*.ts", { ...o });\n', + }; + const reported = checkCode(files); + const unanalysable = [ + "variable", + "fullyComputed", + "concatenation", + "concatenatedLiterals", + "dotPrefix", + "aliasPrefix", + "urlVariable", + "globBaseVariable", + "globOptionsSpread", + ]; + for (const file of unanalysable) { + const lines = linesFor(reported, `src/v1/lib/${file}.ts`); + assert.equal(lines.length, 1, `${file}:\n${reported.join("\n")}`); + assert.ok(lines[0].includes("unanalysable import"), lines[0]); + } + for (const file of ["computedPage", "globAnyFolder"]) { + const lines = linesFor(reported, `src/v1/components/ui/${file}.ts`); + assert.equal(lines.length, 1, `${file}:\n${reported.join("\n")}`); + assert.ok(lines[0].includes("may not import a computed v1 path"), lines[0]); + } + const broken = linesFor(reported, "src/v1/lib/broken.ts"); + assert.equal(broken.length, 1, reported.join("\n")); + assert.ok(broken[0].includes("could not parse"), broken[0]); + assert.deepEqual(linesFor(reported, "src/v1/app/routes.ts"), []); + assert.deepEqual(linesFor(reported, "src/v1/lib/link.ts"), []); +}); + +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`);", + "templatePrefix.jsx": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n) => import(`../v1/lib/${n}`);", + "glob.jsx": 'export const a = import.meta.glob("@v1/pages/*/index.ts");', + "globRootAbsolute.jsx": + 'export const a = import.meta.glob("/src/v1/lib/*.ts");', + "globArrayLater.jsx": + 'export const a = import.meta.glob(["./*.jsx", "../v1/lib/*.ts"]);', + "require.jsx": 'export const a = require("../v1/lib/x");', + "newUrl.jsx": + 'export const a = new URL("../v1/lib/x.ts", import.meta.url);', + "aliasV0.jsx": 'export const a = () => import("@/v1/lib/x");', + "aliasV0Dot.jsx": "export const a = () => import(`@/./v1/lib/x`);", + "srcPath.jsx": 'export const a = () => import("src/v1/lib/x");', + "relativeEntry.jsx": 'import "../v1";', + "typed.tsx": 'import x from "../v1/lib/x";\nexport const y = x;', + "templateAsConst.tsx": + "export const a = () => import(`@v1/plugins/manifest` as const);", + "angleAssertion.ts": + 'export const a = () => import("@v1/plugins/manifest");', + "nonNull.ts": 'export const a = () => import("@v1/lib/x"!);', + "nestedWrappers.ts": + 'export const a = () => import(("@v1/lib/x" as const)!);', + "globArrayAsConst.ts": + 'export const a = import.meta.glob(["@v1/lib/*.ts"] as const);', + "globBase.jsx": + 'export const a = import.meta.glob("./*.ts", { base: "/src/v1/lib" });', + "globClimbsLater.jsx": + 'export const a = import.meta.glob("./*/../../v1/lib/*.ts");', + // Vite globs a concatenation on the string it starts with. + "concatenation.jsx": + 'export const a = (n) => import("../v1/lib/" + n + ".ts");', + "concatCall.jsx": 'export const a = (n) => import("../v1/lib/".concat(n));', + "conditional.jsx": + 'export const a = (c) => import(c ? "../v1/lib/x" : "./b");', + "aliasV0Unslashed.jsx": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n) => import(`@/v1${n}`);", + "aliasUnslashed.jsx": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (p) => import(`@v1${p}`);", + "globWildcard.jsx": 'export const a = import.meta.glob("../**/*.ts");', + "escaped.jsx": 'export const a = () => import("..\\/v1/lib/x");', + "afterJsxText.jsx": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + 'export const P = ({ d, t, x }) =>

{d}/{t} {String(`progress/${x}`)}

;\nexport const later = () => import("@/v1/lib/x");', + "styles.css": '@import "../v1/styles/tokens.css";', + "tailwindReference.css": '@reference "../v1/styles/tokens.css";', + "stylesNoSpace.css": '@import"../v1/styles/tokens.css";', + "stylesUpperCase.css": '@IMPORT "../v1/styles/tokens.css";', + "stylesImageSet.css": + '.a { background-image: image-set("../v1/assets/a.svg" 1x); }', + "stylesEscapedUrl.css": ".a { background: url(\\.\\./v1/assets/a.svg); }", + // Vite can load any file (`?worker`), so an unknown type is read as code. + "worker.mod": 'import "../v1/lib/x";', + "stylesRootAbsolute.css": ".a { background: url(/src/v1/styles/a.png); }", + }; + const allowed = { + "entry.jsx": 'import "@v1";', + "entryTemplate.jsx": "export const a = () => import(`@v1`);", + "entryIndex.jsx": 'import "@v1/index";', + "v0Only.jsx": 'import x from "../helpers/x";\nexport const y = x;', + "v0Template.jsx": + // biome-ignore lint/suspicious/noTemplateCurlyInString: source text containing a template literal. + "export const a = (n) => import(`../helpers/${n}`);", + "memberImport.jsx": "export const a = (api, f) => api.import(f);", + "concatV0.jsx": 'export const a = (n) => import("../helpers/" + n);', + "variableV0.jsx": "export const a = (n) => import(n);", + "globBaseV0.jsx": + 'export const a = import.meta.glob("./*.ts", { base: "../helpers" });', + "lookalike.jsx": + 'import a from "@/api/v1";\nimport { v1 } from "uuid/v1";\nexport const b = [a, v1];', + }; + const files = {}; + for (const [name, code] of Object.entries({ ...denied, ...allowed })) { + files[`src/pages/${name}`] = `${code}\n`; + } + const reported = checkCode(files); + for (const name of Object.keys(denied)) { + const lines = linesFor(reported, `src/pages/${name}`); + assert.equal(lines.length, 1, `${name}:\n${reported.join("\n")}`); + assert.ok(lines[0].includes("reaches into src/v1"), `${name}: ${lines[0]}`); + } + for (const name of Object.keys(allowed)) { + assert.deepEqual(linesFor(reported, `src/pages/${name}`), [], name); + } +}); + +test("v1 styles: imports and Tailwind directives follow the contract", () => { + const denied = { + "reference.css": ['@reference "../../index.css";', "is outside src/v1"], + "plugin.css": ['@plugin "../../../plugins/x.js";', "is outside src/v1"], + "config.css": [ + '@config "../../../tailwind.config.js";', + "is outside src/v1", + ], + "intoPage.css": [ + '@import "@v1/pages/a/x.css";', + "styles/ may not import from pages/a/", + ], + "climbs.css": ['@import "../styles/tokens.css";', "climbs with '../'"], + // At-rules are case-insensitive and need no space before a string. + "noSpace.css": ['@import"../../index.css";', "is outside src/v1"], + "upperCase.css": ['@IMPORT "../../index.css";', "is outside src/v1"], + // A `/*` inside a string is not a comment. + "afterSlashStarInString.css": [ + '.a { content: "/*"; }\n.b { background: url("../../x.png"); }\n/* end */', + "is outside src/v1", + ], + "broken.css": [".a {", "could not parse"], + // Vite reads strings in image-set() and drops backslash escapes. + "imageSet.css": [ + '.a { background-image: image-set("../../assets/a.png" 1x, "../../assets/b.png" 2x); }', + "is outside src/v1", + 2, + ], + "webkitImageSetUrl.css": [ + '.a { background-image: -webkit-image-set(url("../../assets/a.png") 1x); }', + "is outside src/v1", + ], + "escapedUrl.css": [ + '.a { background: url("\\.\\./\\.\\./assets/a.png"); }', + "is outside src/v1", + ], + "importNoTarget.css": ["@import;", "unanalysable import"], + "importUrl.css": ['@import url("../../index.css");', "is outside src/v1"], + }; + const allowed = { + "ok.css": [ + '@import "tailwindcss";', + '@import "@v1/styles/tokens.css";', + '@reference "./tokens.css";', + '@plugin "tailwindcss-animate";', + '.a { background: url("@v1/assets/a.png"); }', + ".b { background: url(data:image/png;base64,AA); }", + '.c { background-image: image-set("@v1/assets/a.png" 1x); }', + ].join("\n"), + }; + const files = {}; + for (const [name, [code]] of Object.entries(denied)) { + files[`src/v1/styles/${name}`] = `${code}\n`; + } + for (const [name, code] of Object.entries(allowed)) { + files[`src/v1/styles/${name}`] = `${code}\n`; + } + const reported = checkCode(files); + for (const [name, [, message, count = 1]] of Object.entries(denied)) { + const lines = linesFor(reported, `src/v1/styles/${name}`); + assert.equal(lines.length, count, `${name}:\n${reported.join("\n")}`); + for (const line of lines) { + assert.ok(line.includes(message), `${name}: ${line}`); + } + } + assert.deepEqual(linesFor(reported, "src/v1/styles/ok.css"), []); +}); + +test("v0 symlinks: a link into v1 is reported, a file link is scanned", () => { + const dir = makeTree(null); + try { + writeFile(dir, "elsewhere/bad.jsx", 'import "@v1/lib/x";\n'); + fs.mkdirSync(path.join(dir, "src", "pages"), { recursive: true }); + fs.symlinkSync( + path.join(dir, "src", "v1", "plugins", "manifest.ts"), + path.join(dir, "src", "pages", "manifest.ts"), + ); + fs.symlinkSync( + path.join(dir, "elsewhere", "bad.jsx"), + path.join(dir, "src", "pages", "bad.jsx"), + ); + const { output, log } = runAll(dir, ["node scripts/check-v1.mjs --code"]); + assert.ok(output.includes("src/pages/manifest.ts: links into src/v1"), log); + assert.ok(output.includes("src/pages/bad.jsx:1: '@v1/lib/x' reaches"), log); + } finally { + removeTree(dir); + } +}); + +test("v0 at the top of src/: a glob `base` is resolved from there", () => { + const reported = checkCode({ + "src/RootGlob.jsx": + 'export const a = import.meta.glob("./*.ts", { base: "v1/lib" });\n', + }); + const lines = linesFor(reported, "src/RootGlob.jsx"); + assert.equal(lines.length, 1, reported.join("\n")); + assert.ok(lines[0].includes("reaches into src/v1"), lines[0]); +}); + +test("v1 --code reads unknown file types and style files outside styles/", () => { + // Cloud CI runs only --code, so it must not rely on --files rejecting these. + const reported = checkCode({ + "src/v1/lib/bridge.mod": 'import "../../store/alert.js";\n', + "src/v1/components/ui/Badge.pcss": + '@import "../../../index.css";\n.ant-btn { color: red; }\n', + }); + const bridge = linesFor(reported, "src/v1/lib/bridge.mod"); + assert.equal(bridge.length, 1, reported.join("\n")); + assert.ok(bridge[0].includes("is outside src/v1"), bridge[0]); + const badge = linesFor(reported, "src/v1/components/ui/Badge.pcss"); + assert.equal(badge.length, 2, reported.join("\n")); + assert.ok(badge[0].includes("is outside src/v1"), badge[0]); + assert.ok(badge[1].includes("No antd 'ant-' class names"), badge[1]); +}); 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-lookalike/src/pages/Lookalike.jsx b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-lookalike/src/pages/Lookalike.jsx new file mode 100644 index 0000000000..04f214831c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-imports-v1-lookalike/src/pages/Lookalike.jsx @@ -0,0 +1,4 @@ +import api from "@/api/v1"; +import { v1 as uuid } from "uuid/v1"; + +export const ids = () => [api, uuid]; 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/v0-tsx-imports-entry/src/V1Switch.tsx b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/V1Switch.tsx new file mode 100644 index 0000000000..33ec55123a --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/V1Switch.tsx @@ -0,0 +1,3 @@ +import { mountV1 } from "@v1"; + +export const mounted: string = mountV1(); diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/app/mount.ts b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/app/mount.ts new file mode 100644 index 0000000000..33dcee7982 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/app/mount.ts @@ -0,0 +1,6 @@ +import Logo from "@v1/assets/logo.svg?react"; +import "@v1/styles/global.css"; + +// An asset, a stylesheet and import.meta.env, so the root typecheck must +// resolve them the way src/v1/tsconfig.json does. +export const mountV1 = (): string => `${import.meta.env.MODE}:${typeof Logo}`; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/assets/logo.svg b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/assets/logo.svg new file mode 100644 index 0000000000..625fd367af --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/assets/logo.svg @@ -0,0 +1 @@ + diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/index.ts b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/index.ts new file mode 100644 index 0000000000..f54562d67c --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/index.ts @@ -0,0 +1 @@ +export { mountV1 } from "@v1/app/mount"; diff --git a/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/styles/global.css b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/styles/global.css new file mode 100644 index 0000000000..ac38c30204 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v0-tsx-imports-entry/src/v1/styles/global.css @@ -0,0 +1,3 @@ +:root { + --v1-accent: #000; +} diff --git a/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/assets/logo.svg b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/assets/logo.svg new file mode 100644 index 0000000000..625fd367af --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/assets/logo.svg @@ -0,0 +1 @@ + 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..feb52d7f4f --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Label.test.ts @@ -0,0 +1,8 @@ +import { renderLabel } from "@v1/test/render"; +import { expect, it } from "vitest"; +import { label } from "./Label"; + +// Importing vitest here keeps typecheck:v1 honest about test typings. +it("renders", () => { + expect(renderLabel(label("ok"))).toBe("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/Logo.tsx b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Logo.tsx new file mode 100644 index 0000000000..9ca41d3948 --- /dev/null +++ b/frontend/test-fixtures/v1-boundary/cases/v1-allowed-imports/src/v1/components/ui/Logo.tsx @@ -0,0 +1,5 @@ +import LogoIcon from "@v1/assets/logo.svg?react"; + +export function Logo() { + return