Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions frontend/scripts/check-v1.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,84 @@ function* notifyEntryPoints(ast, specs) {
}
}

// P0-T05: backend URLs and requests only in services/ (and plugins' services/); tests may name URLs for mocks.
const BACKEND_URL_HOMES = new Set(["services", "test"]);
Comment thread
kirtimanmishrazipstack marked this conversation as resolved.
Resolved
// A cloud plugin keeps its own service modules in plugins/<plugin>/services/.
const PLUGIN_SERVICES = /^plugins\/[^/]+\/services\//;
// v1 calls the backend on its own origin, so its URLs are root-relative.
const BACKEND_URL = /^\/(api|deployment)\//;
const AXIOS = /^axios(\/|$)/;
// The client and orgPath(), under any spelling of its path.
const HTTP_CLIENT_MODULE = "services/http";
const CODE_SUFFIX = /\.[cm]?[jt]sx?$/;
// Browser APIs that skip the client's CSRF, request id and 401 handling.
const RAW_REQUESTS = new Set(["fetch", "XMLHttpRequest", "EventSource"]);
const GLOBAL_OBJECTS = new Set(["window", "globalThis", "self"]);
const BACKEND_URL_MSG =
"Backend URLs appear only in services/ and plugins/<plugin>/services/; call a typed service function instead.";
const HTTP_CLIENT_MSG =
"Only services/ and plugins/<plugin>/services/ import the HTTP client (axios, @v1/services/http); call a typed service function instead.";
const RAW_REQUEST_MSG =
"Only services/ and plugins/<plugin>/services/ send requests (fetch, XMLHttpRequest, EventSource); call a typed service function instead.";

const isBackendHome = (folder, rel) =>
BACKEND_URL_HOMES.has(folder) || PLUGIN_SERVICES.test(rel);

function isHttpClient(spec, fileRel) {
if (AXIOS.test(spec)) {
return true;
}
if (!spec.startsWith("@v1/") && !RELATIVE.test(spec)) {
Comment thread
kirtimanmishrazipstack marked this conversation as resolved.
Resolved
return false;
}
const target = resolveV1(spec, fileRel).replace(CODE_SUFFIX, "");
return target === HTTP_CLIENT_MODULE;
}

/* `fetch(...)`, `new XMLHttpRequest()`, `new window.EventSource(...)` and the like. */
function isRawRequest(node) {
if (node.type !== "CallExpression" && node.type !== "NewExpression") {
return false;
}
const callee = unwrap(node.callee);
if (callee?.type === "Identifier") {
return RAW_REQUESTS.has(callee.name);
}
return (
callee?.type === "MemberExpression" &&
!callee.computed &&
GLOBAL_OBJECTS.has(unwrap(callee.object)?.name) &&
RAW_REQUESTS.has(callee.property.name)
);
}

/* The text a string or template starts with, or undefined for other nodes. */
function literalStart(node) {
if (node.type === "StringLiteral") {
return node.value;
}
return node.type === "TemplateLiteral"
? node.quasis[0].value.cooked
: undefined;
}

/* HTTP-client imports, raw requests and backend URL literals, as { index, msg }. */
function* backendCalls(ast, specs, fileRel) {
for (const arg of specs) {
if (isHttpClient(arg.spec ?? "", fileRel)) {
yield { index: arg.index, msg: HTTP_CLIENT_MSG };
}
}
for (const node of nodes(ast.program)) {
if (isRawRequest(node)) {
yield { index: node.start, msg: RAW_REQUEST_MSG };
}
if (BACKEND_URL.test(literalStart(node) ?? "")) {
yield { index: node.start, msg: BACKEND_URL_MSG };
}
}
}
Comment thread
kirtimanmishrazipstack marked this conversation as resolved.
Resolved

/* PropTypes and antd `ant-` class names, as { index, msg }. */
function* bannedUses(ast, src) {
for (const node of nodes(ast.program)) {
Expand Down Expand Up @@ -883,6 +961,11 @@ for (const rel of walk(ROOT)) {
report(rel, lineAt(src, index), msg);
}
}
if (!isTestFile && !isBackendHome(folder, rel)) {
for (const { index, msg } of backendCalls(parsed.ast, specs, rel)) {
report(rel, lineAt(src, index), msg);
}
}
for (const { index, msg } of bannedUses(parsed.ast, src)) {
report(rel, lineAt(src, index), msg);
}
Expand Down
22 changes: 19 additions & 3 deletions frontend/src/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ is convention.
- `CodeEditor` uses Monaco from its default CDN, as v0 does. Never call
`loader.config()` in v1: the loader is shared with v0.

## Data layer

- Server state goes through TanStack Query with the typed functions in
`services/`; `services/README.md` has the contract. `V1App` mounts the one
QueryClient (`app/query.ts`), which reports every failure.
- Render a read with `QueryView` (`components/patterns/`): skeleton, inline
error with Retry, then the data. Forms map field errors with
`mapFieldErrors()` from `@v1/services/errors`.
- `lint:v1` rejects a string or template starting `/api/` or `/deployment/`,
any import of axios or `@v1/services/http`, and any call to `fetch`,
`XMLHttpRequest` or `EventSource`, outside `services/`,
`plugins/<plugin>/services/`, `test/` and test files.

## Version switch and route registry

v0's router (`routes/Router.jsx`) wraps its routes in `VersionSwitch`, which
Expand All @@ -128,7 +141,10 @@ only when a v1 page first renders.
`REGISTERED_PAGES`. Its v0 URLs then redirect to v1; reverting is that line.
v1 renders outside v0's `PersistentLogin` and `RequireAuth`, and its own
session and guards come with P0-T06: a page registered before that ships
with no session load, login redirect or org check.
with no session load or org check. Until something sets the org
(`setOrganizationId()`), its queries throw and it shows the error fallback;
it leaves for sign-in only when a service call gets a 401
(`services/http.ts`).
- `app/registry/validate.test.ts` fails CI on an invalid entry: overlapping
patterns across pages, a v1 route and a different v0 route of one page that
match the same URL (a page may keep its v0 URL), a route without a mapping
Expand Down Expand Up @@ -173,7 +189,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`,
| `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/<page>/` | 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/` |
| `services/` | Typed service modules and the HTTP client; with a cloud plugin's `plugins/<plugin>/services/`, the only place a backend URL appears (see `services/README.md`) | `lib/` |
| `stores/` | Zustand stores | `lib/` |
| `hooks/` | Shared hooks | `lib/` |
| `lib/` | Pure helpers | — |
Expand All @@ -187,7 +203,7 @@ Every folder may import from itself. Test files (`*.test.ts(x)`,
| Script | Checks |
| --- | --- |
| `bun run typecheck:v1` | TypeScript-only files, styles only in `styles/`, allowed file types only, no symlinks, folder taxonomy; then `tsc` with `src/v1/tsconfig.json` |
| `bun run lint:v1` | Biome on `src/v1/` (warnings fail), then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, the single notification entry point, then the styling policy (S1 to S8 above), then the icon rules |
| `bun run lint:v1` | Biome on `src/v1/` (warnings fail), then imports in code and in `styles/` CSS (nothing outside v1, folder contract, and v0 reaching v1 only through `@v1`), PropTypes and `ant-` checks, the single notification entry point, backend URLs, the HTTP client and raw requests only in `services/`, then the styling policy (S1 to S8 above), then the icon rules |
| `bun run tokens:v1` | Regenerates `styles/tokens.css` from `styles/tokens.json` (via `styles/tokens.ts`; runs under bun, which imports the TypeScript source directly) |
| `bun run test:v1` | v1 unit tests (`vitest.v1.config.mts`, then cloud plugins with `vitest.v1-plugins.config.mts`) with v1's own coverage threshold, then the guardrail fixtures in `test-fixtures/v1-boundary/`, `test-fixtures/v1-harness/` and `test-fixtures/v1-unit-gate/` (the coverage threshold fails a run, and CI runs the rig group's config). The harness is documented in `test/README.md` |
| `bun run seam:v1` | Seam guard: v1 work changes v0 (`src/` outside v1, `public/`) only at the files in `scripts/v1-seam-allowlist.json`, compared with `origin/main` |
Expand Down
32 changes: 20 additions & 12 deletions frontend/src/v1/app/V1App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
* guard. Loaded lazily by the version switch, so v1's code reaches the
* browser only when a v1 page renders; its styles are in the global
* stylesheet (vite.config.js sets cssCodeSplit: false). The Toaster mounts
* here, once, so `notify()` reaches the user on every v1 page. Session,
* guards (P0-T06) and the shell (P2-T01) mount here.
* here, once, so `notify()` reaches the user on every v1 page, and so does
* the one QueryClient. Session, guards (P0-T06) and the shell (P2-T01) mount
* here.
*/

import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@v1/app/query";
import type { RegistryEntry } from "@v1/app/registry/types";
import { ThemeProvider } from "@v1/app/theme/ThemeProvider";
import { Toaster } from "@v1/components/ui/feedback";
Expand All @@ -16,21 +19,26 @@ import { PAGE_COMPONENTS } from "./pages";

export type V1AppProps = Readonly<{ entry: RegistryEntry }>;

// One cache for the v1 tree, kept across v1 pages.
const queryClient = createQueryClient();

export function V1App({ entry }: V1AppProps): JSX.Element {
const Page = PAGE_COMPONENTS[entry.page];
if (!Page) {
throw new Error(`v1 page ${entry.page} is registered but has no component`);
}
return (
<ThemeProvider>
<Suspense fallback={null}>
<Routes>
{entry.v1Routes.map((path) => (
<Route key={path} path={path} element={<Page />} />
))}
</Routes>
</Suspense>
<Toaster />
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<Suspense fallback={null}>
<Routes>
{entry.v1Routes.map((path) => (
<Route key={path} path={path} element={<Page />} />
))}
</Routes>
</Suspense>
<Toaster />
</ThemeProvider>
</QueryClientProvider>
);
}
10 changes: 9 additions & 1 deletion frontend/src/v1/app/VersionSwitch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { buildLiveRegistry, type LiveRegistry } from "@v1/app/registry/live";
import { toast } from "@v1/lib/notify";
import { oldVersionStorageKey } from "@v1/lib/oldVersion";
import { useOldVersionStore } from "@v1/stores/oldVersion";
import { HttpResponse, http as mock, mockApi } from "@v1/test";
import { useEffect } from "react";
import {
MemoryRouter,
Expand All @@ -15,7 +16,7 @@ import {
useLocation,
useNavigate,
} from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { VersionSwitch } from "./VersionSwitch";

// Two test-only pages beside the real demo ones: one that fails to render on
Expand Down Expand Up @@ -102,11 +103,18 @@ function renderAt(

const where = () => screen.getByTestId("where").textContent;

// Load the lazy v1 modules once, so a test's wait is not spent loading them.
beforeAll(() => Promise.all([import("./V1App"), import("@v1/pages/demo")]));

beforeEach(() => {
visited = [];
window.localStorage.clear();
window.sessionStorage.clear();
useOldVersionStore.setState({ userId: null, oldVersion: null, chosen: {} });
// The demo page lists adapters (UN-4313).
mockApi(
mock.get("/api/v1/unstract/:org/adapter/", () => HttpResponse.json([])),
);
});

describe("VersionSwitch", () => {
Expand Down
Loading
Loading