# Build, lint, and test

> The commands CI enforces and their ordering: `pnpm build` (recursive), `pnpm lint` composed of `lint:check` (oxlint) and `types:check` (recursive `tsc --noEmit`), and `pnpm test` (`node --test scripts/*.test.js` plus per-package suites). Documents generator prerequisites, the oxlint rule posture, and why type-aware rules are disabled in this monorepo.

- Repository: cloudflare/cloudflare-os
- GitHub: https://github.com/cloudflare/cloudflare-os
- Human docs: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd
- Complete Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/llms-full.txt

## Source Files

- `package.json`
- `.oxlintrc.json`
- `.gitlab-ci.yml`
- `packages/workshop-backend/package.json`
- `tsconfig.json`
- `AGENTS.md`

---

---
title: "Build, lint, and test"
description: "The commands CI enforces and their ordering: `pnpm build` (recursive), `pnpm lint` composed of `lint:check` (oxlint) and `types:check` (recursive `tsc --noEmit`), and `pnpm test` (`node --test scripts/*.test.js` plus per-package suites). Documents generator prerequisites, the oxlint rule posture, and why type-aware rules are disabled in this monorepo."
---

The repository root is a private pnpm workspace named `gadgets` whose `package.json` scripts fan out recursively to packages. CI runs two blocking jobs on merge requests and on the default branch: a `lint` job that runs `pnpm lint`, and a `test` job that runs `pnpm build` followed by `pnpm test`. Both jobs share a `before_script` that downloads a pinned Node 22.14.0 tarball, verifies its SHA-256, enables pnpm via `corepack enable`, initializes git submodules, and installs with `pnpm install --frozen-lockfile`.

## Root scripts

| Script | Command |
| --- | --- |
| `build` | `pnpm run --recursive build` |
| `test` | `node --test scripts/*.test.js && pnpm run --recursive --if-present test` |
| `lint` | `pnpm run lint:check && pnpm run types:check` |
| `lint:check` | `oxlint` |
| `lint:fix` | `oxlint --fix` |
| `types:check` | `pnpm run --recursive --if-present types:check` |
| `clean` | `pnpm run --recursive clean` |
| `run-local` | `node scripts/run-local.mjs` |
| `dev-server` | `node run-dev-server.js` |
| `dev-client` | `cd packages/workshop-frontend && pnpm run dev` |

Root dev dependencies pin the toolchain: `oxlint ^1.76.0`, `typescript ^5.9.3`, `vite ^7.3.6`, `wrangler ^4.115.0`, plus `aws4fetch` and `jsonc-parser`.

<Note>
`test` and `types:check` use `--if-present`, so a package without that script is skipped rather than failing the run. `build` is recursive without `--if-present`.
</Note>

## What CI enforces, in order

<Steps>
<Step title="Environment setup (both jobs)">
Pinned Node is fetched and checksum-verified, `corepack enable` provides pnpm, `git submodule update --init` populates submodules, then `pnpm install --frozen-lockfile`. A lockfile that does not match `package.json` fails install before any script runs.
</Step>
<Step title="lint job">
`pnpm lint` → `oxlint` across the workspace, then recursive `tsc --noEmit` via `types:check`. Lint is blocking.
</Step>
<Step title="test job">
`pnpm build` first, then `pnpm test`. The build step is part of the test job's script, so a broken build fails the test job.
</Step>
<Step title="review stage (merge requests only)">
An included CI component (`cloudflare/ci/ai/opencode`) runs AI code review in the `review` stage with `runOnMR: true` and `ASSIGN_REVIEWERS: false`. It is not part of build/lint/test.
</Step>
</Steps>

The two `test`-stage jobs run on the `vm-linux-x86-4cpu-8gb` runner tag under shared rules: merge request events and the default branch.

```text
install (pinned node + corepack + submodules + frozen lockfile)
   │
   ├── lint job ──► pnpm lint ──► oxlint ──► types:check (recursive tsc --noEmit)
   │
   └── test job ──► pnpm build (recursive) ──► pnpm test
                                                 ├── node --test scripts/*.test.js
                                                 └── recursive per-package test
```

## Generator prerequisites

Several packages generate source before the compiler or test runner sees it, so generators are wired into each script rather than into a single global prebuild step. In `packages/workshop-backend`, `build`, `types:check`, `test`, `test:integration`, and `test:watch` all run `node build-browser-runtime.mjs` and `node scripts/build-format-blueprints.mjs` first:

```json
{
  "build": "node build-browser-runtime.mjs && node scripts/build-format-blueprints.mjs && tsc",
  "types:check": "node build-browser-runtime.mjs && node scripts/build-format-blueprints.mjs && tsc --noEmit",
  "test": "node build-browser-runtime.mjs && node scripts/build-format-blueprints.mjs && vitest run && vitest run --config vitest.integration.config.ts"
}
```

`scripts/build-format-blueprints.mjs` globs `format-blueprints/` (each blueprint is a `<name>.gadget` archive plus a `<name>.json` sidecar) into the gitignored `src/generated/format-blueprints.ts`. `FORMAT_BLUEPRINTS_DIR` overrides the source directory so a fork can ship its own set. Because the output is gitignored, `build`, `types:check`, and `test` each run the generator so a clean checkout compiles.

Gatekeeper configurator UI modules are compiled by `scripts/build-gatekeeper-configurator.mjs` as part of package builds; `packages/gatekeeper-context` bundles its `app/` SPA with `build-app.mjs` into `src/generated/app.txt`.

<Warning>
Do not run `tsc --noEmit` directly in a package with generated inputs on a fresh checkout — go through the package's `types:check` script so the generators run first. Missing generated modules on a clean checkout is a known failure mode.
</Warning>

Backend package scripts that are not part of the root pipeline:

| Script | Purpose |
| --- | --- |
| `build:worker` | `node build-browser-runtime.mjs && pnpm exec capnweb-validate build --out .wrangler/validate` |
| `build:format-blueprints` | Run the format-blueprint generator alone |
| `import:format-blueprint` | `node --experimental-strip-types --no-warnings scripts/import-format-blueprint.mjs` |
| `test:integration` | Generators, then `vitest run --config vitest.integration.config.ts` |
| `test:watch` | Generators, then `vitest` in watch mode |
| `dev` | Errors out with a pointer to run `pnpm dev-server` at the root |
| `clean` | `rm -rf dist` |

## Test layers

`pnpm test` runs two layers:

- `node --test scripts/*.test.js` — the root Node test runner over repository scripts.
- `pnpm run --recursive --if-present test` — per-package suites. `workshop-backend` runs Vitest twice: the default config, then `vitest.integration.config.ts`.

Vitest in the backend uses `@cloudflare/vitest-pool-workers` and `vitest ^4.1.10`.

## Type checking

Type safety is enforced by real `tsc`, not by the linter. The root `tsconfig.json` sets the shared compiler posture and is not itself a project that compiles files:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ESNext"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "allowJs": true,
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": [],
  "exclude": ["node_modules", "dist"]
}
```

`include: []` means the root config contributes options only; each package's own `types:check` drives the actual check.

## oxlint posture

`.oxlintrc.json` enables the `correctness` and `suspicious` categories at `error`, with base plugins `typescript`, `unicorn`, `oxc`, and `import`, under `env.es2024`.

### Rules turned off, and why

| Rule | Setting | Reason |
| --- | --- | --- |
| `import/default` | `off` | Gatekeepers import `.txt` files as bundled text assets; the resolver reports them as having no default export. |
| `import/no-unassigned-import` | `off` | Side-effect imports are deliberate: CSS (`./styles.css`) and the `cloudflare:workers` runtime registration import. |
| `unicorn/no-empty-file` | `off` | Comment-only placeholder/reference modules are kept intentionally (for example `App.tsx`, `gatekeeper-cloudflare/src/types.d.ts`). |
| `no-underscore-dangle` | `off` | Conflicts with the convention of prefixing intentionally-unused bindings with `_`. |

### Rules kept as warnings

`no-shadow`, `typescript/no-this-alias`, `typescript/no-extraneous-class`, and `unicorn/consistent-function-scoping` are set to `warn`: considered genuine improvements but too churny to block CI during initial rollout, left visible for incremental cleanup.

### Unused-variable policy

```json
"no-unused-vars": [
  "error",
  {
    "args": "none",
    "caughtErrors": "none",
    "varsIgnorePattern": "^_",
    "ignoreRestSiblings": true
  }
]
```

Unused callback/interface parameters and catch bindings are not flagged; unused imports and local variables still are, and a `_` prefix opts a variable out.

### Ignored paths

```text
**/dist/**
**/generated/**
**/*.gen.ts
**/node_modules/**
**/.wrangler/**
**/worker-configuration.d.ts
```

Generated output is linted nowhere: `**/generated/**` and `**/*.gen.ts` cover the format-blueprint and configurator-UI outputs.

### Per-area overrides

| Files | Added plugins | Env |
| --- | --- | --- |
| `packages/workshop-frontend/**/*.{ts,tsx}` | `react`, `jsx-a11y` | `browser`, `es2024` |
| `packages/gatekeeper-*/**/*.tsx` | `react` | `browser`, `es2024` |
| `packages/workshop-backend/**/*.ts`, `packages/router/**/*.ts`, `packages/gatekeeper-*/src/**/*.ts`, `packages/workshop-shared/**/*.ts`, `packages/typed-storage/**/*.ts` | — | `serviceworker`, `es2024` |
| `**/*.test.ts`, `**/*.test.tsx`, `**/vitest.config.ts` | `vitest` | `vitest`, `es2024` |
| `scripts/**/*.mjs`, `*.js`, `*.mjs` | — | `node`, `es2024` |

Gatekeeper configurator UIs get the `react` plugin but not `jsx-a11y`, because they use a classic JSX runtime with the `h` pragma rather than the automatic react-jsx runtime.

### The capnweb import restriction

`packages/integration-tests/**/*.ts` forbids value imports of `capnweb`:

```json
"no-restricted-imports": [
  "error",
  {
    "paths": [
      {
        "name": "capnweb",
        "message": "Mint stubs via stubFor() from rpc-client: a consumer repo can hold two capnweb copies, and a stub from the wrong one fails to serialise. `import type` is fine.",
        "allowTypeImports": true
      }
    ]
  }
]
```

A repository that vendors this one as a submodule installs both workspaces, ending up with two copies of `capnweb`; a stub minted by one copy is unserializable by the other's session — a failure that only appears once installs are split, i.e. in CI. Value imports are therefore confined to `packages/integration-tests/src/rpc-client.ts`, which wraps stub minting in `stubFor()` and is the single file where the rule is turned back `off`. `import type` remains allowed everywhere.

## Why type-aware linting is disabled

The config comment states the constraint directly: the type-aware engine uses tsgo (TypeScript 7), which requires an explicit `rootDir` when emitting declarations and has dropped `baseUrl`. This monorepo emits declarations while importing sibling-package *source* files via `paths`, so any `rootDir` that satisfies tsgo would break the real `tsc` build with TS6059. Type-aware rules stay off and full type safety comes from `tsc` through `types:check`.

```text
sibling source imports via `paths` + declaration emit
        │
        ├── real tsc build: OK (no rootDir constraint)
        └── tsgo type-aware lint: needs rootDir ──► TS6059 in the real build
                                                   ⇒ type-aware rules off
```

## Local usage

<CodeGroup>
```bash title="Full CI-equivalent sweep"
pnpm install --frozen-lockfile
pnpm lint
pnpm build
pnpm test
```

```bash title="Lint only"
pnpm lint:check      # oxlint
pnpm lint:fix        # oxlint --fix
pnpm types:check     # recursive tsc --noEmit (runs generators)
```

```bash title="Backend package only"
pnpm --filter @gadgets/workshop-backend run types:check
pnpm --filter @gadgets/workshop-backend run test
pnpm --filter @gadgets/workshop-backend run test:integration
```
</CodeGroup>

<Check>
CI order is the safe local order too: `pnpm lint` catches oxlint and type errors without needing build artifacts, then `pnpm build` before `pnpm test` matches the test job exactly.
</Check>

<Warning>
This project is pnpm-only, and CI installs with `--frozen-lockfile`. Using npm or yarn locally will desynchronize the lockfile and fail the CI install step.
</Warning>

## Troubleshooting

<AccordionGroup>
<Accordion title="Cannot find module './generated/format-blueprints'">
The file is gitignored and produced by `scripts/build-format-blueprints.mjs`. Run the package's `build`, `types:check`, or `test` script (each runs the generator), or run `pnpm --filter @gadgets/workshop-backend run build:format-blueprints` directly.
</Accordion>
<Accordion title="oxlint reports 'no default export' on a .txt import">
That is the known false positive from bundled text assets; `import/default` is already `off` in `.oxlintrc.json`. If it still fires, confirm the file is not being linted under an override that reintroduces the rule.
</Accordion>
<Accordion title="Errors flagged only in CI for integration tests">
Check for a value `import ... from 'capnweb'` in `packages/integration-tests`. Convert it to `import type`, or mint stubs through `stubFor()` in `rpc-client.ts`. The duplicate-`capnweb` failure only materializes when the workspace is installed as a submodule alongside a consumer workspace.
</Accordion>
<Accordion title="Type error that oxlint did not catch">
Expected: type-aware linting is intentionally disabled. Run `pnpm types:check` (recursive `tsc --noEmit`) — that is the only type gate.
</Accordion>
<Accordion title="pnpm install fails in CI">
`--frozen-lockfile` rejects a lockfile that does not match the manifests, and the job also runs `git submodule update --init` beforehand — a missing submodule or a stale lockfile both fail before any script executes.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Installation" href="/installation">Prerequisites, pinned Node 22.14.0, and the install paths including `pnpm run-local`.</Card>
<Card title="Local development" href="/local-development">The two-terminal `pnpm dev-server` / `pnpm dev-client` workflow.</Card>
<Card title="Integration testing" href="/integration-testing">How `packages/integration-tests` boots real workers in workerd and speaks Cap'n Web.</Card>
<Card title="Developer conventions and contributing" href="/conventions-and-contributing">pnpm-only rules, kernel review standards, and RPC conventions.</Card>
<Card title="Manage bundled format blueprints" href="/bundled-format-blueprints">`FORMAT_BLUEPRINTS_DIR`, the generated module, and `pnpm import:format-blueprint`.</Card>
<Card title="Troubleshooting" href="/troubleshooting">Missing generated modules and other known failure modes.</Card>
</CardGroup>
