Skip to content

feat: add caching options to cli #1059

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 35 commits into from
Aug 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
bb5afa3
feat: add persist.report output to CLI
BioPhoton Aug 8, 2025
b992caf
fix: revert changes
BioPhoton Aug 8, 2025
9b9616e
Merge remote-tracking branch 'origin/main' into add-caching-options-t…
BioPhoton Aug 11, 2025
ccfc1df
fix: adjust models
BioPhoton Aug 11, 2025
894cf68
test: adjust e2e tests for caching
BioPhoton Aug 11, 2025
26d5f7b
test: add int tests for caching
BioPhoton Aug 11, 2025
2688343
test: fix int tests
BioPhoton Aug 11, 2025
8f421a6
test: add e2e tests
BioPhoton Aug 11, 2025
30e904c
docs: add docs
BioPhoton Aug 11, 2025
e8ac86f
fix: fix lint violation
BioPhoton Aug 11, 2025
f510678
docs: update Nx example
BioPhoton Aug 12, 2025
d5e9fab
docs: update caching examples
BioPhoton Aug 12, 2025
44f7b7c
Merge remote-tracking branch 'origin/main' into add-caching-options-t…
BioPhoton Aug 12, 2025
f9d63b8
Update packages/cli/src/lib/implementation/core-config.options.ts
BioPhoton Aug 12, 2025
b2e4fac
Update packages/cli/README.md
BioPhoton Aug 12, 2025
fd8c37e
Update packages/cli/docs/nx-caching.md
BioPhoton Aug 12, 2025
bf1718c
Update packages/cli/docs/nx-caching.md
BioPhoton Aug 12, 2025
caf8053
Update packages/cli/docs/turbo-caching.md
BioPhoton Aug 12, 2025
f3b9223
Update packages/cli/docs/nx-caching.md
BioPhoton Aug 12, 2025
531bedb
fix: update help output with caching options
BioPhoton Aug 12, 2025
520faa5
docs: fix typos
BioPhoton Aug 12, 2025
3d1c1cd
docs: adjust command example
BioPhoton Aug 12, 2025
4373c4c
docs: adjust command example
BioPhoton Aug 12, 2025
121ba4e
docs: adjust config example
BioPhoton Aug 12, 2025
0b52c07
Update packages/cli/docs/nx-caching.md
BioPhoton Aug 12, 2025
5fb3d87
docs: options docs
BioPhoton Aug 12, 2025
fdcd289
docs: adjust nx targets
BioPhoton Aug 12, 2025
9e6702a
docs: adjust nx targets 2
BioPhoton Aug 12, 2025
005d49e
Update packages/cli/docs/turbo-caching.md
BioPhoton Aug 12, 2025
ba7f967
Update packages/cli/docs/turbo-caching.md
BioPhoton Aug 12, 2025
76e3f86
docs: adjust turbo targets 1
BioPhoton Aug 12, 2025
b80b659
docs: add category filter
BioPhoton Aug 12, 2025
2fede24
docs: fix typo
BioPhoton Aug 12, 2025
98dedef
docs: fix target outputs
BioPhoton Aug 12, 2025
612dcb8
docs: fix turbo docs
BioPhoton Aug 12, 2025
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
8 changes: 8 additions & 0 deletions e2e/cli-e2e/tests/__snapshots__/help.e2e.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ Global Options:
-p, --onlyPlugins List of plugins to run. If not set all plugins are run.
[array] [default: []]

Cache Options:
--cache Cache runner outputs (both read and write)
[boolean]
--cache.read Read runner-output.json from file system
[boolean]
--cache.write Write runner-output.json to file system
[boolean]

Persist Options:
--persist.outputDir Directory for the produced reports
[string]
Expand Down
30 changes: 29 additions & 1 deletion e2e/cli-e2e/tests/collect.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import {
TEST_OUTPUT_DIR,
teardownTestFolder,
} from '@code-pushup/test-utils';
import { executeProcess, fileExists, readTextFile } from '@code-pushup/utils';
import {
executeProcess,
fileExists,
readJsonFile,
readTextFile,
} from '@code-pushup/utils';
import { dummyPluginSlug } from '../mocks/fixtures/dummy-setup/dummy.plugin';

describe('CLI collect', () => {
const dummyPluginTitle = 'Dummy Plugin';
Expand Down Expand Up @@ -61,6 +67,28 @@ describe('CLI collect', () => {
expect(md).toContain(dummyAuditTitle);
});

it('should write runner outputs if --cache is given', async () => {
const { code } = await executeProcess({
command: 'npx',
args: ['@code-pushup/cli', '--no-progress', 'collect', '--cache'],
cwd: dummyDir,
});

expect(code).toBe(0);

await expect(
readJsonFile(
path.join(dummyOutputDir, dummyPluginSlug, 'runner-output.json'),
),
).resolves.toStrictEqual([
{
slug: 'dummy-audit',
score: 0.3,
value: 3,
},
]);
});

it('should not create reports if --persist.skipReports is given', async () => {
const { code } = await executeProcess({
command: 'npx',
Expand Down
55 changes: 43 additions & 12 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,18 +207,40 @@ Each example is fully tested to demonstrate best practices for plugin testing as

### Common Command Options

| Option | Type | Default | Description |
| --------------------------- | -------------------- | -------- | --------------------------------------------------------------------------- |
| **`--persist.outputDir`** | `string` | n/a | Directory for the produced reports. |
| **`--persist.filename`** | `string` | `report` | Filename for the produced reports without extension. |
| **`--persist.format`** | `('json' \| 'md')[]` | `json` | Format(s) of the report file. |
| **`--persist.skipReports`** | `boolean` | `false` | Skip generating report files. (useful in combination with caching) |
| **`--upload.organization`** | `string` | n/a | Organization slug from portal. |
| **`--upload.project`** | `string` | n/a | Project slug from portal. |
| **`--upload.server`** | `string` | n/a | URL to your portal server. |
| **`--upload.apiKey`** | `string` | n/a | API key for the portal server. |
| **`--onlyPlugins`** | `string[]` | `[]` | Only run the specified plugins. Applicable to all commands except `upload`. |
| **`--skipPlugins`** | `string[]` | `[]` | Skip the specified plugins. Applicable to all commands except `upload`. |
#### Global Options

| Option | Type | Default | Description |
| ---------------------- | ---------- | ------- | ------------------------------------------------------------------------------ |
| **`--onlyPlugins`** | `string[]` | `[]` | Only run the specified plugins. Applicable to all commands except `upload`. |
| **`--skipPlugins`** | `string[]` | `[]` | Skip the specified plugins. Applicable to all commands except `upload`. |
| **`--onlyCategories`** | `string[]` | `[]` | Only run the specified categories. Applicable to all commands except `upload`. |
| **`--skipCategories`** | `string[]` | `[]` | Skip the specified categories. Applicable to all commands except `upload`. |

#### Cache Options

| Option | Type | Default | Description |
| ------------------- | --------- | ------- | --------------------------------------------------------------- |
| **`--cache`** | `boolean` | `false` | Cache runner outputs (both read and write). |
| **`--cache.read`** | `boolean` | `false` | If plugin audit outputs should be read from file system cache. |
| **`--cache.write`** | `boolean` | `false` | If plugin audit outputs should be written to file system cache. |

#### Persist Options

| Option | Type | Default | Description |
| --------------------------- | -------------------- | -------- | ------------------------------------------------------------------ |
| **`--persist.outputDir`** | `string` | n/a | Directory for the produced reports. |
| **`--persist.filename`** | `string` | `report` | Filename for the produced reports without extension. |
| **`--persist.format`** | `('json' \| 'md')[]` | `json` | Format(s) of the report file. |
| **`--persist.skipReports`** | `boolean` | `false` | Skip generating report files. (useful in combination with caching) |

#### Upload Options

| Option | Type | Default | Description |
| --------------------------- | -------- | ------- | ------------------------------ |
| **`--upload.organization`** | `string` | n/a | Organization slug from portal. |
| **`--upload.project`** | `string` | n/a | Project slug from portal. |
| **`--upload.server`** | `string` | n/a | URL to your portal server. |
| **`--upload.apiKey`** | `string` | n/a | API key for the portal server. |

> [!NOTE]
> All common options, except `--onlyPlugins` and `--skipPlugins`, can be specified in the configuration file as well.
Expand Down Expand Up @@ -327,3 +349,12 @@ In addition to the [Common Command Options](#common-command-options), the follow
| Option | Required | Type | Description |
| ------------- | :------: | ---------- | --------------------------------- |
| **`--files`** | yes | `string[]` | List of `report-diff.json` paths. |

## Caching

The CLI supports caching to speed up subsequent runs and is compatible with Nx and Turborepo.

Depending on your strategy, you can cache the generated reports files or plugin runner output.
For fine-grained caching, we suggest caching plugin runner output.

The detailed example for [Nx caching](./docs/nx-caching.md) and [Turborepo caching](./docs/turbo-caching.md) is available in the docs.
106 changes: 106 additions & 0 deletions packages/cli/docs/nx-caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Caching Example Nx

To cache plugin runner output, you can use the `--cache.write` and `--cache.read` options in combination with `--onlyPlugins` and `--persist.skipReports` command options.

## `{projectRoot}/code-pushup.config.ts`

```ts
import coveragePlugin from '@code-pushup/coverage-plugin';
import jsPackagesPlugin from '@code-pushup/js-packages-plugin';
import type { CoreConfig } from '@code-pushup/models';

export default {
plugins: [
await coveragePlugin({
reports: ['coverage/lcov.info'],
}),
await jsPackagesPlugin(),
],
upload: {
server: 'https://api.code-pushup.example.com/graphql',
organization: 'my-org',
project: 'lib-a',
apiKey: process.env.CP_API_KEY,
},
} satisfies CoreConfig;
```

## `{projectRoot}/project.json`

```json
{
"name": "lib-a",
"targets": {
"int-test": {
"cache": true,
"outputs": ["{options.coverage.reportsDirectory}"],
"executor": "@nx/vite:test",
"options": {
"configFile": "packages/lib-a/vitest.int.config.ts",
"coverage.reportsDirectory": "{projectRoot}/coverage/int-test"
}
},
"unit-test": {
"cache": true,
"outputs": ["{options.coverage.reportsDirectory}"],
"executor": "@nx/vite:test",
"options": {
"configFile": "packages/lib-a/vitest.unit.config.ts",
"coverage.reportsDirectory": "{projectRoot}/coverage/unit-test"
}
},
"code-pushup-coverage": {
"cache": true,
"outputs": ["{projectRoot}/.code-pushup/coverage"],
"executor": "nx:run-commands",
"options": {
"command": "npx @code-pushup/cli collect",
"args": ["--config={projectRoot}/code-pushup.config.ts", "--cache.write=true", "--persist.skipReports=true", "--persist.outputDir={projectRoot}/.code-pushup", "--upload.project={projectName}"]
},
"dependsOn": ["unit-test", "int-test"]
},
"code-pushup": {
"cache": true,
"outputs": ["{projectRoot}/.code-pushup"],
"executor": "nx:run-commands",
"options": {
"command": "npx @code-pushup/cli",
"args": ["--config={projectRoot}/code-pushup.config.ts", "--cache.read=true", "--persist.outputDir={projectRoot}/.code-pushup", "--upload.project={projectName}"]
},
"dependsOn": ["code-pushup-coverage"]
}
}
}
```

## Nx Task Graph

This configuration creates the following task dependency graph:

**Legend:**

- 🐳 = Cached target

```mermaid
graph TD
A[lib-a:code-pushup 🐳] --> B[lib-a:code-pushup-coverage 🐳]
B --> C[lib-a:unit-test 🐳]
B --> D[lib-a:int-test 🐳]
```

## Command Line Example

```bash
# Run all affected project plugins `coverage` and cache the output if configured
nx affected --target=code-pushup-coverage

# Run all affected projects with plugins `coverage` and `js-packages` and upload the report to the portal
nx affected --target=code-pushup
```

This approach has the following benefits:

1. **Parallel Execution**: Plugins can run in parallel
2. **Fine-grained Caching**: Code level cache invalidation enables usage of [affected](https://nx.dev/recipes/affected-tasks) command
3. **Dependency Management**: Leverage Nx task dependencies and its caching strategy
4. **Clear Separation**: Each plugin has its own target for better debugging and maintainability
98 changes: 98 additions & 0 deletions packages/cli/docs/turbo-caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Caching Example Turborepo

To cache plugin runner output with Turborepo, wire Code Pushup into your turbo.json pipeline and pass Code Pushup flags (`--cache.write`, `--cache.read`, `--onlyPlugins`, `--persist.skipReports`) through task scripts. Turborepo will cache task outputs declared in outputs, and you can target affected packages with `--filter=[origin/main]`.

## `{projectRoot}/code-pushup.config.ts`

```ts
import coveragePlugin from '@code-pushup/coverage-plugin';
import jsPackagesPlugin from '@code-pushup/js-packages-plugin';
import type { CoreConfig } from '@code-pushup/models';

export default {
plugins: [
await coveragePlugin({
reports: ['coverage/lcov.info'],
}),
await jsPackagesPlugin(),
],
upload: {
server: 'https://api.code-pushup.example.com/graphql',
organization: 'my-org',
project: 'lib-a',
apiKey: process.env.CP_API_KEY,
},
} satisfies CoreConfig;
```

## Root `turbo.json`

```json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"unit-test": {
"outputs": ["coverage/unit-test/**"]
},
"int-test": {
"outputs": ["coverage/int-test/**"]
},
"code-pushup-coverage": {
"dependsOn": ["unit-test", "int-test"],
"outputs": [".code-pushup/coverage/**"]
},
"code-pushup": {
"dependsOn": ["code-pushup-coverage"],
"outputs": [".code-pushup/**"]
}
}
}
```

## `packages/lib-a/package.json`

```json
{
"name": "lib-a",
"scripts": {
"unit-test": "vitest --config packages/lib-a/vitest.unit.config.ts --coverage",
"int-test": "vitest --config packages/lib-a/vitest.int.config.ts --coverage",
"code-pushup-coverage": "code-pushup collect --config packages/lib-a/code-pushup.config.ts --cache.write --persist.skipReports --persist.outputDir packages/lib-a/.code-pushup --onlyPlugins=coverage",
"code-pushup": "code-pushup autorun --config packages/lib-a/code-pushup.config.ts --cache.read --persist.outputDir packages/lib-a/.code-pushup"
}
}
```

> **Note:** `--cache.write` is used on the collect step to persist each plugin's audit-outputs.json; `--cache.read` is used on the autorun step to reuse those outputs.

## Turborepo Task Graph

This configuration creates the following task dependency graph:

**Legend:**

- ⚡ = Cached target (via outputs)

```mermaid
graph TD
A[lib-a:code-pushup ⚡] --> B[lib-a:code-pushup-coverage]
B --> C[lib-a:unit-test ⚡]
B --> D[lib-a:int-test ⚡]
```

## Command Line Examples

```bash
# Run all affected project plugins `coverage` and cache the output if configured
turbo run code-pushup-coverage --filter=[origin/main]

# Run all affected projects with plugins `coverage` and `js-packages` and upload the report to the portal
turbo run code-pushup --filter=[origin/main]
```

This approach has the following benefits:

1. **Parallel Execution**: Plugins can run in parallel
2. **Finegrained Caching**: Code level cache invalidation enables usage of affected packages filtering
3. **Dependency Management**: Leverage Turborepo task dependencies and its caching strategy
4. **Clear Separation**: Each plugin has its own target for better debugging and maintainability
15 changes: 15 additions & 0 deletions packages/cli/src/lib/implementation/core-config.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { autoloadRc, readRcByPath } from '@code-pushup/core';
import {
type CacheConfig,
type CacheConfigObject,
type CoreConfig,
DEFAULT_PERSIST_FILENAME,
DEFAULT_PERSIST_FORMAT,
Expand Down Expand Up @@ -41,6 +43,7 @@ export async function coreConfigMiddleware<
tsconfig,
persist: cliPersist,
upload: cliUpload,
cache: cliCache,
...remainingCliOptions
} = processArgs;
// Search for possible configuration file extensions if path is not given
Expand All @@ -59,8 +62,10 @@ export async function coreConfigMiddleware<
...rcUpload,
...cliUpload,
});

return {
...(config != null && { config }),
cache: normalizeCache(cliCache),
persist: buildPersistConfig(cliPersist, rcPersist),
...(upload != null && { upload }),
...remainingRcConfig,
Expand All @@ -79,5 +84,15 @@ export const normalizeBooleanWithNegation = <T extends string>(
? false
: ((rcOptions?.[propertyName] as boolean) ?? true);

export const normalizeCache = (cache?: CacheConfig): CacheConfigObject => {
if (cache == null) {
return { write: false, read: false };
}
if (typeof cache === 'boolean') {
return { write: cache, read: cache };
}
return { write: cache.write ?? false, read: cache.read ?? false };
};

export const normalizeFormats = (formats?: string[]): Format[] =>
(formats ?? []).flatMap(format => format.split(',') as Format[]);
Loading
Loading