Docs/Run and test

Run and test

Execute Apex, build test data, diagnose failures, and measure test quality.

HTTP Mocking

There are two separate contexts for HTTP in Nimbus, and they work differently:

  • In tests - Salesforce does not allow real callouts inside @isTest methods. Use Test.setMock() with a standard HttpCalloutMock implementation. Nimbus enforces the same rule as a real org.
  • In nimbus exec - Apex runs outside of a test context, so Test.setMock() is not available. Use --mocks to provide a config file that Nimbus uses to intercept and respond to any Http.send() calls.

Test Callouts

Salesforce does not allow real HTTP callouts inside @isTest methods - any call to Http.send()without a registered mock throws System.CalloutException. Nimbus enforces the same rule.

The solution is the standard Salesforce pattern: implement HttpCalloutMock and register it with Test.setMock(). Nimbus supports this natively - no special setup required.

Basic example

apex
@isTest
private class UserServiceTest {

    private class UsersMock implements HttpCalloutMock {
        public HttpResponse respond(HttpRequest req) {
            HttpResponse res = new HttpResponse();
            res.setStatusCode(200);
            res.setHeader('Content-Type', 'application/json');
            res.setBody('[{"id": 1, "name": "Alice"}]');
            return res;
        }
    }

    @isTest
    static void testFetchUsers() {
        Test.setMock(HttpCalloutMock.class, new UsersMock());

        Test.startTest();
        List<User__c> users = UserService.fetchUsers();
        Test.stopTest();

        Assert.areEqual(1, users.size());
        Assert.areEqual('Alice', users[0].Name);
    }
}

This code deploys and runs identically on Salesforce and in Nimbus. No Nimbus-specific classes or imports needed.

Multiple endpoints

A single mock class handles all callouts made during the test. Use the request method and endpoint to route responses:

apex
private class ApiMock implements HttpCalloutMock {
    public HttpResponse respond(HttpRequest req) {
        HttpResponse res = new HttpResponse();
        res.setStatusCode(200);
        res.setHeader('Content-Type', 'application/json');

        if (req.getMethod() == 'GET' && req.getEndpoint().contains('/users')) {
            res.setBody('[{"id": 1}]');
        } else if (req.getMethod() == 'POST' && req.getEndpoint().contains('/orders')) {
            res.setStatusCode(201);
            res.setBody('{"id": "ord-001"}');
        }

        return res;
    }
}

Exec Mocking

When running Apex outside of a test context with nimbus exec, Test.setMock() is not available. Any Http.send() call needs to be intercepted at the Nimbus level.

Pass a mock config file with --mocks. Nimbus intercepts all Http.send() calls and returns the matching configured response - no real network request is made.

Using a mock file

bash
nimbus exec MyClass.run --mocks nimbus.mocks.yaml
yaml
# nimbus.mocks.yaml
- method: GET
  url: https://api.example.com/v1/users
  status: 200
  body: '[{"id": 1, "name": "Alice"}]'
  contentType: application/json

- method: POST
  url: https://api.example.com/v1/orders
  status: 201
  body: '{"id": "ord-001", "status": "created"}'
  headers:
    X-Request-Id: abc-123

Inline mocks

For quick one-offs, define mocks directly on the command line:

bash
# method:url:status:body
nimbus exec MyClass.run --mock "GET:https://api.example.com/v1/users:200:[]"

# multiple --mock flags
nimbus exec MyClass.run \
  --mock "GET:https://api.example.com/v1/token:200:{"token":"abc"}" \
  --mock "POST:https://api.example.com/v1/submit:201:{"ok":true}"

Unmatched callouts

If Apex calls an endpoint with no matching mock entry, Nimbus throws an error and halts execution. Use --allow-unmocked to let unmatched calls fall through to the real network instead.

bash
nimbus exec MyClass.run --mocks nimbus.mocks.yaml --allow-unmocked

Sequential responses

List multiple responses for the same endpoint to return them in order. The last response repeats once the list is exhausted.

yaml
# First call → 429, second call onward → 200
- method: GET
  url: https://api.example.com/v1/items
  responses:
    - status: 429
      body: '{"error": "rate limited"}'
      contentType: application/json
    - status: 200
      body: '{"items": [{"id": 1}]}'
      contentType: application/json

Config Reference

Full schema for entries in nimbus.mocks.yaml:

FlagDefaultDescription
method-HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, TRACE
url-Full URL or URL pattern to match against
status200HTTP status code returned in the response
body""Response body string
contentTypeapplication/jsonValue for the Content-Type response header
headers{}Map of additional response headers (key: value)
responses-List of sequential responses (overrides body/status/contentType for that entry)

Response object (used inside responses:)

FlagDefaultDescription
status200HTTP status code for this response
body""Response body string
contentTypeapplication/jsonContent-Type for this response
headers{}Additional response headers

Committing mock files

Commit nimbus.mocks.yaml alongside your code so the whole team uses the same mock definitions. Use multiple files to separate concerns - one per integration, one per feature area:

bash
nimbus exec MyClass.run --mocks mocks/auth.yaml --mocks mocks/orders.yaml

URL Patterns Pro

Match endpoints by glob pattern instead of exact URL. Useful when your code constructs URLs with dynamic IDs or path parameters.

Wildcard syntax

FlagDefaultDescription
*-Matches any single path segment - no slashes
**-Matches any path, including multiple segments and slashes
yaml
# * matches one segment: /api/v1/users/123/profile, /api/v1/users/abc/profile
- method: GET
  url: https://api.example.com/v1/users/*/profile
  status: 200
  body: '{"name": "Alice"}'

# ** matches any path: /api/v1/orders/123/items/456
- method: DELETE
  url: https://api.example.com/v1/orders/**
  status: 204

Priority

Exact URLs always win over patterns. Among patterns, longer (more specific) ones win. If two patterns are equally specific, the first entry in the file wins.

yaml
# Priority: low → high (top to bottom in this list)

- method: GET
  url: https://api.example.com/v1/users/**    # broadest - lowest priority
  body: '{"fallback": true}'

- method: GET
  url: https://api.example.com/v1/users/*/settings    # wins for .../users/123/settings
  body: '{"theme": "dark"}'

- method: GET
  url: https://api.example.com/v1/users/me    # exact URL - highest priority
  body: '{"id": "me", "admin": true}'

Commands

nimbus test

Run Apex test classes locally. This is the core command you'll use every day.

Pattern matching

bash
nimbus test                              # Run all tests
nimbus test MyTestClass                  # All methods in a class
nimbus test MyTestClass.testMyMethod     # Single test method
nimbus test Calculator*                  # Classes starting with "Calculator"
nimbus test *Test                        # Classes ending with "Test"
nimbus test *Calculator*                 # Classes containing "Calculator"

Path patterns

bash
nimbus test force-app/main/default/classes/MyTest.cls   # Specific file
nimbus test force-app/main/default/classes/              # All in directory

Running nimbus test with no pattern from inside a package subdirectory (e.g. after cd’ing intoforce-app/main/default/classes/billing) auto-scopes to that subdirectory instead of the whole project.

An explicit pattern that matches no tests is an error: the run reports No tests ran and exits non-zero, so a typo’d pattern can’t produce a green CI run.

A run that discovers no tests at all is an error for the same reason, and it is the one CI actually hits: a packageDirectories entry pointing at a path that doesn’t exist, a checkout that never fetched the classes, a container missing the source mount. The pipeline would otherwise go green having run nothing. Nimbus exits 1 and names the two things worth checking. On a project that genuinely has no tests, pass --allow-empty to keep the warning and exit 0.

bash
nimbus test "*"                 # no classes found -> exit 1
nimbus test "*" --allow-empty   # no classes found -> warning, exit 0

Intentional failures

A test class whose leading doc comment contains EXPECTED TO FAIL (case-insensitive) declares its failures deliberate — a demo fixture, a documented reproduction, an org-comparison probe. Those failures are tagged [expected], listed after the real ones, counted separately in the summary line (✗ 2 test(s) failed · 5 intentional demo failures), reported as expected_failures under --json, and excluded from the exit code. Anything else that fails still exits non-zero.

Parallel execution Pro

bash
nimbus test -p 8     # 8 parallel workers (Pro)
nimbus test -p 16    # Push it harder on fast machines
nimbus test           # Sequential (Free tier default)

Coverage

bash
nimbus test --coverage                              # Print summary to console
nimbus test --coverage --coverage-output json       # JSON to console (for scripting)
nimbus test --coverage --coverage-report out.json   # Save JSON to file
nimbus test --coverage --coverage-report out.html   # Save HTML report
nimbus test --coverage --coverage-report out.xml    # Save Cobertura XML (Pro)

Org fallback Pro

bash
nimbus test -f -o my-org    # Fall back to SF CLI when Nimbus can't handle a test
nimbus test --fetch-missing -o my-org  # Fetch missing metadata and re-run

When Nimbus surfaces missing metadata (Apex classes, custom fields, custom labels, custom objects, custom metadata types), it also writes a standard SFDX manifest at manifest/nimbus-missing.xml — regardless of whether --fetch-missing is set. You can hand it to sf directly: sf project retrieve start --manifest manifest/nimbus-missing.xml -o <alias>. The filename is distinct from package.xml so it won't clobber a user-managed deployment manifest.

A class that is in your project but fails to parse is never listed here, and never lands in the manifest or the unresolved-type summary — retrieving it would overwrite the file you're fixing. Nimbus reports it as a parse error instead, printed before the tests run: the file is excluded from the run, every call into it returns null, and each failing test that reached it says so under its assertion.

The same applies to a class Nimbus parses but Salesforce would refuse to compile. Nimbus prints the org's own message and line before the tests run, excludes the class, and fails the command: a suite cannot report green on code an org would reject. The same check is what nimbus validate reports and what the editor squiggles red. What it covers:

  • a variable, parameter or field named with one of the platform's reserved words (from, limit, group, string, …)
  • a call to a method that does not exist — unqualified, through this., on one of your own classes, or on an SObject (Method does not exist or incorrect signature)
  • a static member that does not exist on one of your classes (Variable does not exist)
  • an assignment the target type cannot hold, including from a method's declared return type (Illegal assignment from String to Integer)

Unresolved type references. A name your source uses as a receiver that resolves to nothing — no class in your project, no platform type, no SObject, and no managed-package namespace you have declared — is listed in its own block before the tests run, with how often the project writes it and where it first appears. Nimbus returns null for it at runtime, so a test that passes over one has not proved much. It does not fail the command: auto-stubbing an unresolved reference is how a project runs against packages it has no source for. Declare the namespace in nimbus.properties (nimbus.stubs.namespaces=Hoplog) or drop a stub under stubs/ and the block goes quiet; leave it undeclared and nimbus validate and the editor both report it as the compile error the org reports.

Standard objects with no local model. A reference to a standard Salesforce object Nimbus ships no describe for — AccountContactRelation, TaskRelation, CollaborationGroupRecord and the like, most of them behind an org feature toggle — is reported in its own block rather than as a missing Apex class or custom object. It is deliberately kept out of manifest/nimbus-missing.xml: sf project retrieve start cannot retrieve a standard object under any manifest type. The remedy is a describe, and the report prints it per object: nimbus sync -s AccountContactRelation -o <alias>. In nimbus test --json the same list appears under unmodelled_standard_objects, apart from the missing-metadata count. --fetch-missing describes them from the org along with everything else.

Tracing / debugging

bash
nimbus test --trace                          # Record a replayable trace
nimbus test --trace --trace-level normal     # Cheaper, not replayable
nimbus test --trace --trace-output ./traces  # Save traces to custom dir

Org defaults (custom settings)

Seed custom setting org-default records before tests run. Can be specified via CLI flag or committed to nimbus.properties for repeatability.

bash
# CLI flag (one field per flag, or comma-separated fields)
nimbus test --org-default "TriggerSettings__c.IsEnabled__c=true"
nimbus test --org-default "MySettings__c.Field1__c=foo,Field2__c=bar"

# nimbus.properties (committable, profile-aware)
nimbus.seed.org-default.TriggerSettings__c=IsEnabled__c=true
nimbus.seed.org-default.MySettings__c=Field1__c=foo,Field2__c=bar

Exclusions

bash
nimbus test -e __tests__       # Exclude paths containing "__tests__"
nimbus test -e legacy -e wip   # Multiple exclusions
FlagDefaultDescription
-p, --parallel1Number of parallel test workers (Pro)
--shard-Run a subset of tests: --shard N/M. Use with CI matrix to parallelize across jobs.
--local-shards-Run N shards concurrently on this machine, each with its own embedded Postgres: a number (e.g. 4) or "auto" (Pro)
--impactedfalseRun only tests affected by source changes since the impact map was last built. Selects tests whose coverage touched a changed class, plus changed and never-before-seen test classes. Falls back to a full run when no map exists or a schema file changed (sfdx-project.json, objects/). The map is rebuilt after each run.
-n, --dry-runfalseList matching tests without executing them. Under --json the selection comes back in the tests array with status "skipped" and summary.status "skipped", so a caller can enumerate a shard without scraping the human listing.
--timeout60Per-test timeout in seconds (catches infinite loops). Covers class loading too, so a static initializer that never terminates fails the class — reporting every method — instead of hanging the run.
--sandboxfalseMake Organization.IsSandbox return true during tests
-P, --assign-perms-Assign PermissionSet(s) to the mock running user by API name (repeatable, comma-separated)
--show-permission-seamsfalseExpand the permission-seam summary into a per-test list. Custom-permission seams ($Permission, FeatureManagement.checkPermission) are flagged distinctly from FLS / object-perm seams.
--strict-permissionsfalseFail tests whose passing path consulted a permission check outside System.runAs() (CI gate)
--feature-Enable org features: MultiCurrency, PersonAccounts (repeatable, comma-separated)
--faketime-Pin Date.today() / DateTime.now() to this instant (e.g. 2030-01-15T12:00:00Z)
--coveragefalseEnable code coverage collection
--coverage-outputconsoleCoverage output format: console, json
--coverage-report-Path to save coverage report - format auto-detected from extension: .json, .html, .xml Cobertura (Pro). Implies --coverage.
--evidencefalseRecord what each line actually did - executions, SOQL queries, DML statements and database time - to .nimbus/evidence/last-run.json. Read it back with nimbus coverage lines. Collects line data without printing a coverage report.
--results-xml-Path to save JUnit XML test results (Pro)
-f, --fallbackfalseFall back to SF CLI for unsupported features (Pro)
--fetch-missingfalseRetrieve missing metadata and re-run
--recordfalseForward managed-package static calls to the org and record what they return, into .nimbus/recordings/ (one file per test method). Requires an explicit -o and runs single-threaded. Re-recording a method replaces its file wholesale. The calls really execute in the org, so prefer a scratch or developer org. Recording intercepts calls to stub classes only — on a project without stubs/, use nimbus record, which pulls the stubs first.
--no-replayfalseIgnore recordings in .nimbus/recordings/ and run the stub bodies instead. Replay itself needs no flag: recordings present means they are used.
--org-default-Seed a custom setting org default before tests: ObjectName__c.Field=value (repeatable)
-e, --exclude-Exclude paths matching a substring or glob — ** spans directories, e.g. **/dist/** (repeatable; persist via nimbus.test.exclude). Case-insensitive, like the positional pattern. Excluded paths are not compiled, so this removes code rather than only deselecting tests — to split a suite across jobs use --shard instead.
-i, --interactivefalseInteractively select package scope
--allow-emptyfalseExit 0 when the project contains no tests at all. Without it that run exits 1, so CI cannot go green on a misconfigured package directory. An explicit pattern that matches nothing still exits 1 either way. A --shard that draws no tests is never affected — an empty shard is normal on a suite with fewer classes than shards.
-q, --quietfalseMinimal output — show only the summary and failures
--jsonfalseOutput results as JSON (for CI/agent consumption). stdout carries the JSON document and nothing else — progress and warnings go to stderr, so the stream always parses.
--in-orgfalseRun the tests in the Salesforce org instead of locally. Same table, same --json document, same replay command per failure — Nimbus enqueues the run, reports each method as the org finishes it, and converts each one's debug log into a trace in .nimbus/traces. Selection is by name only (a class, Class.method, or a comma-separated list); a glob has no org equivalent and is refused. Flags describing local execution (-p, --record, --impacted, --shard, --local-shards, --faketime, --sandbox, --readonly, --feature) are refused rather than ignored.
--tracefalseRecord a replayable execution trace. Defaults to on with --in-org: an org run cannot be re-traced afterwards, because the debug log only exists while the tests execute. Pass --trace=false to skip it.
--trace-levelverboseTrace verbosity: minimal, normal, verbose, debug, system. Below verbose the trace can be read but not replayed.
--trace-output.nimbus/tracesDirectory for trace output files
--no-trace-on-failurefalseDo not record a replayable trace for failed tests. Persist via nimbus.test.trace-on-failure=false.

Replay on failure

When a run has failures, Nimbus re-runs just the failed tests with tracing on and prints the command that reopens each one:

bash
Failed Tests:

  AccountServiceTest.testCreate
    Assertion failed: expected 99, got 4

    Class.AccountServiceTest.testCreate: line 12, column 1
    replay: nimbus trace AccountServiceTest.testCreate --run 2026-08-26T02-08-59_5ba1f24e

This is on by default and costs nothing on a green run — nothing is recorded and nothing is written. Traces are recorded at verbose, the level the replay debugger needs to step line by line.

Tests whose class declares its failures deliberate are skipped, and a test that passes when re-run is reported as such rather than presented as a recording of the failure. With --json, each failed test carries trace_path, trace_run_id and replay, so an editor can offer "replay this failure" without guessing where the trace landed.

Retention is bounded by count and by size: the last nimbus.test.trace-on-failure.keep automatic traces are kept, and the oldest are pruned until they fit inside nimbus.test.trace-on-failure.max-total-bytes. Traces you recorded yourself with --trace are never pruned.

One recording is bounded too. A trace stops at nimbus.test.trace.max-bytes; what was written still parses and still replays, up to the point it was cut, and the run says so on the last line. Set either key to 0 to remove the bound. Traces live in .nimbus/traces, one directory per recorded run — delete the ones you no longer need.

FlagDefaultDescription
nimbus.test.trace-on-failuretrueRecord a replayable trace for failed tests
nimbus.test.trace-on-failure.keep3How many automatic failure traces to retain; oldest pruned
nimbus.test.trace-on-failure.max-tests25Cap on how many failed tests get retraced in one run
nimbus.test.trace-on-failure.max-total-bytes2147483648Total byte budget for automatic failure traces; oldest pruned until they fit (0 = unlimited)
nimbus.test.trace.max-bytes536870912Byte cap on one run's trace; the recording stops there and is marked truncated (0 = unlimited)

Parallel sharding

Split your test suite across multiple CI jobs with --shard N/M. Job N runs only its assigned slice of test classes. All N jobs run concurrently in a CI matrix, cutting wall-clock time proportionally.

Classes are distributed by recorded test duration (not just count), so shards finish in roughly equal wall time even when a few classes dominate the runtime. Duration data is recorded automatically by full test runs and persisted in your project; a project with no history yet falls back to an even split by class name. All methods within a class are always kept together so @testSetup runs correctly.

When combined with --results-xml, each shard's JUnit XML output includes a shard="N/M" attribute on the root <testsuites> element, allowing CI aggregators to merge results across shards.

yaml
# GitHub Actions matrix example
strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: nimbus test --shard ${{ matrix.shard }}/4 --format junit

Tuning parallel execution

The default of 8 workers is a safe starting point for most machines and projects. You can push higher, but there's a ceiling — and it's not CPU or memory.

Why conflicts happen

Each parallel test worker runs inside its own database transaction. When two workers insert, update, or delete rows in the same table at the same time, PostgreSQL acquires row-level locks. If worker A holds a lock that worker B needs (and vice versa), you get a deadlock or a lock timeout.

Nimbus handles this automatically: transient database errors (deadlocks, lock timeouts, constraint violations) are retried up to 3 times with exponential backoff. If retries are exhausted, the test is re-run sequentially after the parallel phase completes. Your test still passes — it just takes longer. To disable this sequential re-run fallback, set nimbus.test.rerun-failed=false in nimbus.properties.

The problem is probability. With 4 workers, lock conflicts are rare. With 8, they're occasional and retries handle them. With 16+, conflicts become frequent enough that retries are exhausted, sequential re-runs pile up, and total wall-clock time can actually increase.

Finding your sweet spot

The optimal worker count depends on your codebase, not your hardware. Projects where tests touch many different objects can run more workers. Projects where most tests insert into the same few objects (Account, Contact, Lead) hit contention earlier.

bash
# Start with 8 workers
nimbus test -p 8

# If you see 0 sequential re-runs, try pushing higher
nimbus test -p 12

# If you see many sequential re-runs, back off
nimbus test -p 4

# CI: use nimbus.properties so the settings are committed
%ci.nimbus.test.parallel=8
# Disable sequential re-run fallback if you want hard failures instead
%ci.nimbus.test.rerun-failed=false

Watch the test summary for "re-run sequentially" messages. If you see more than a handful, your worker count is too high for the contention profile of your project. Back off until re-runs drop to near zero.

Scaling beyond the single-machine limit

If you've maxed out parallel workers on one machine and still want faster runs, use --shard to distribute across multiple CI jobs. Sharding splits tests across entirely separate processes — each with their own database — so there's zero lock contention between shards. This scales linearly.

Local shards Pro

--local-shards N runs the same sharding model on one machine: N concurrent nimbus test processes, each with its own embedded Postgres instance, splitting the suite the same way --shard does. Pass a number, or auto to let Nimbus decide: it picks a shard count based on available cores, but won't shard at all if the suite's recorded run time (from the last full run, tracked in .nimbus/test-durations.json) is too short to be worth the per-shard process overhead — or if there's no recorded run yet. Tune the cutoff with nimbus.test.local-shards-auto-threshold-ms (default 8000).

bash
nimbus test --local-shards 4       # 4 concurrent shards, 4 separate databases
nimbus test --local-shards auto    # Let Nimbus choose (by cores and suite length)

Each shard gets a persistent background daemon (visible in nimbus daemon list) that stays warm across runs, so repeat invocations skip database startup. Results, JUnit XML, and JSON output are merged into a single report as if the suite had run in one process. --local-shards is mutually exclusive with --shard, requires the embedded database provider, and does not currently support --coverage, --dry-run, --interactive, --write-stubs, --fetch-missing, or --impacted.

Each shard is a separate OS process, so it pays its own startup cost — Go runtime init, daemon handshake, config parsing — once per invocation. On short suites that fixed cost can outweigh the benefit of a second Postgres instance; on longer-running suites it's a smaller fraction of total wall time, so the separate-database win has more room to show through. If you're not seeing a win, try a lower shard count before a higher one — splitting workers across more processes also means fewer workers per shard, which can offset the extra database parallelism.

Per-worker isolation (opt-in)

Set nimbus.test.isolation=per-worker-schema to give each worker its own cloned Postgres schema. Row-level locks and writes stay inside the worker, so -p can scale to your CPU count without cross-worker contention. Default is shared to keep startup cost low — see Parallel Isolation for details.

nimbus test:watch Pro

Continuous testing mode. Watches your Apex files and automatically re-runs tests whenever you save.

bash
nimbus test:watch              # Watch all tests
nimbus test:watch -i           # Select package scope interactively

While watching:

bash
r    # Manually trigger a re-run
c    # Clear the output
q    # Quit (or Ctrl+C)

Watch mode uses test impact selection automatically once an impact map exists: the first run runs everything and builds the map, and each later run narrows to the tests affected by the files you changed (e.g. 3 of 412 tests impacted by AccountService.cls), falling back to a full run when a schema file changes. Pass --no-impact to run the full pattern on every change.

FlagDefaultDescription
-i, --interactivefalseSelect package scope interactively before watching
--no-impactfalseDisable automatic test impact selection; run the full pattern on every change

nimbus validate

Check Apex files for syntax errors and apiVersion mismatches without running tests. Fast — no database or runtime needed. Errors come from the parser; warnings come from the apiVersion check, which reads each file’s *-meta.xml for the declared apiVersion and flags any referenced symbol whose introduction version is newer than that. Catches mistakes like a v52 class callingDatabase.getCursor (v66) before the deploy does.

Beyond warnings, validate flags a handful of deploy blockers — things Salesforce rejects at compile/deploy — and exits non-zero when any are found: REST resource methods with an unsupported return type; setting an AutoNumber field in an SObject constructor (new Log__c(Name = 'x') — AutoNumber fields aren’t writeable); and a concrete class that declaresimplements but doesn’t define every method of the interface. The field and interface checks resolve schema and interfaces from the local project, and skip conservatively when a type can’t be resolved (e.g. a managed-package or cross-file interface) so they never block a deploy they can’t be sure of.

validate also runs semantic analysis across your whole project’s type universe — resolving every type reference, variable, method call, and field against your classes, the Salesforce standard library, and your SObject schema. It flags things the parser accepts but the platform rejects at deploy: unknown types, undefined variables (including through the inheritance chain), unknown, wrong-arity, or wrong-argument-type methods and constructors (s.frobnicate(),add(1, 'two') against add(Integer, Integer),new Widget(1, 2) with no matching constructor — on the standard library and on your own classes), calling another class's private method, instance members from static context (and statics through an instance), duplicate fields, methods, and variables (including block-scope and parameter shadowing), constructing an abstract class, extending a non-virtual class, overriding a non-virtual method or omitting the override keyword, unimplemented abstract methods, reassigning a final field, missing returns and unreachable statements, break outside a loop, illegal assignments, returns, and ternaries (Integer x = 'hello';,List<Integer> xs = new List<String>();, assigning avoid call), non-Boolean conditions, catching or throwing non-exceptions, DML on non-records, iterating non-collections (and loop variables that can't hold the element type), impossible casts and always-false instanceof, incompatible comparisons, ! and ++ on non-numeric types, interface conformance (implementing every interface method — and notimplements-ing a class, or the same interface twice),switch rules (non-switchable expression types, duplicate and type-mismatched when values), SOQL bind variables and unknown columns, source-absent unnamespaced custom SObjects (checked-in SFDX and stubs metadata is authoritative), typed SObject field assignments, integer-only SOQL LIMIT/OFFSET binds, and unknown SOSL return fields,Datetime-to-Date narrowing, this()/super()constructor chaining, private field access across classes, protected members and@isTest methods in the wrong kind of class, abstract methods with bodies, access modifiers on interface methods, @future and@InvocableMethod shape rules, test-setup and web-exposure annotation contracts (@AuraEnabled, @RemoteAction, REST, andwebservice), illegal class, constructor, field, and method modifiers, annotation placement and context rules for @testSetup,@ReadOnly, and HTTP methods, REST URL mappings and per-verb method cardinality, property, nested-class, interface-method, and enum modifiers, circular self-inheritance, constructor-chain placement, generic type arity, inner-class nesting and static rules, abstract/static modifier combinations, catch ordering, property accessor visibility, global members in non-global classes, duplicate and unknown enum values, duplicate declaration scopes, protected/static field combinations, nested override visibility and return types, class-qualified instance calls, generic array shapes,@InvocableVariable field contracts, annotation property names,@SuppressWarnings arguments, duplicate and illegally modified method parameters, interface return and visibility contracts, classes extending interfaces, instance-initializer returns, construction of the built-in Exception, conflicting sharing modes, @AuraEnabled enums, private@RemoteAction methods, @TestVisible parameters, and unknown fields on your classes and SObjects. Method and conversion rules run against a catalog generated from the platform compiler's own symbol table and are verified against real check-only deploys — quirks likeDatabase.insert(records, false), ''.isBlank(), variables that shadow type names, methods named like their class, conversion-covariant collections, and keyword-less abstract-method implementations all resolve exactly as the org compiler would.

The current differential corpus catches 233 of 235 org-rejected error classes, each verified against a real check-only deploy, with zero intrinsic false rejects in the deployable-source gate. The two deferred cases both require managed-package context to decide whether an identifier may use @Deprecated.

Resolution is conservative by design: anything it can’t disprove — managed-package namespaces, inherited members, and unsynced field types — resolves rather than flags, so it never reports a deployable file. Every rule is gated on a corpus of thousands of real, deployable open-source classes before release. Findings are deploy blockers by default — like the platform, validate fails on code a check-only deploy would reject. Use --sema=warn to downgrade findings to warnings (e.g. while a stale schema sync is being refreshed), or --sema=off to disable.

bash
nimbus validate                  # Validate all Apex files
nimbus validate MyClass          # Files matching "MyClass"
nimbus validate *Service*        # Files matching "*Service*"
nimbus validate -e __tests__     # Exclude paths containing "__tests__"
nimbus validate --sema=warn      # Downgrade semantic findings to warnings
nimbus validate --json           # Machine-readable JSON output (for editor integration)

Path patterns

Naming a single file is the same command as naming a class: the whole project’s type universe is still built from disk, so an unknown method or a field the SObject doesn’t have is reported fornimbus validate path/to/MyClass.cls exactly as it is fornimbus validate MyClass. The project root is found from the file, not from your shell, so it works from anywhere.

bash
nimbus validate force-app/main/default/classes/MyClass.cls   # Specific file
nimbus validate force-app/main/default/classes/               # All files in directory
nimbus validate src/tests/                                    # All files under path
FlagDefaultDescription
-e, --exclude-Exclude paths matching a substring or glob — ** spans directories, e.g. **/dist/** (repeatable)
-i, --interactivefalseSelect package scope interactively
--semaerrorSemantic analysis level: error (default, deploy-blocker), warn, or off.
--jsonfalseOutput results as JSON, including apiVersion and semantic warnings under a per-file warnings[] array.

Suppress an individual apiVersion warning with // nimbus:ignore on the same line as the call (or the line directly above). Add a comma-separated rule list to scope to specific rules.

Run from inside a package subdirectory (e.g. after cd’ing intoforce-app/main/default/classes/billing) to auto-scope to that subdirectory instead of the whole project — no pattern argument needed. Same behavior applies to nimbus test.

For CI gates, the standalone apex-version-lint CLI runs the same check against an SFDX project tree and exits non-zero with -strict:

bash
apex-version-lint              # warn-only, exit 0
apex-version-lint -strict      # exit 1 if any diagnostic was emitted
apex-version-lint path/to/src  # walk a specific subtree

nimbus lint

The governor-safety and best-practice checks every Salesforce developer is trained to look for: SOQL, DML, async enqueues and callouts inside loops, hardcoded record Ids, swallowed exceptions, and seeAllData tests. Where nimbus validate asks whether the platform would accept the file, lint asks whether you want what it does.

Every other Apex linter is purely static, so every one of them says the same thing about a query inside a for: that it might run many times. Nimbus ran the code. Point it at a recorded run and the finding stops being a warning about the future and becomes a report about the past.

bash
nimbus lint                            # every class and trigger
nimbus lint AccountService             # files matching "AccountService"
nimbus lint *Service*                  # files matching "*Service*"
nimbus lint force-app/main/default/classes/   # everything under a path
nimbus lint --rules                    # what each rule protects against
nimbus lint --json                     # schema nimbus.lint/v1

Without a run, it speaks in the conditional

bash
AccountService.cls
  5:35  warning  soql-in-loop
    SOQL query inside the for-each loop at line 4. Every iteration that
    reaches it spends one of the transaction's 100 queries.

1 finding: 1 soql-in-loop in 2 files.

No recorded run: every message above says what the code MIGHT do.
Record one to see what it did:  nimbus test "*" --evidence

With one, it reports what happened

bash
nimbus test "*" --evidence
nimbus lint

AccountService.cls
  5:35  warning  soql-in-loop
    SOQL query inside the for-each loop at line 4. Run
    2026-08-27T17-35-45_0031534d issued it 7 times in
    AccountServiceTest.testOneOwner — the most in any one transaction —
    and 91 times across the run. A transaction may issue 100.

Two numbers, because every governor ceiling is per transaction. The first is what the line did in the single heaviest transaction that reached it — the only number the 100 can be read against — and the second is the total across the whole run, which is the sum over every test that touched the line. When one transaction did all of it, there is one number and the message gives one: issued it 47 times in AccountServiceTest.testBulkInsert.

A message never claims anything ran unless a recording covers that exact line. When a run covered the file and missed the line, the finding says so instead of guessing — untested code carrying a governor-safety defect is a sharper finding than the defect alone, and it is one no static linter can produce.

The rules

FlagDefaultDescription
soql-in-loopwarningA SOQL or SOSL literal, or Database.query / Search.query, inside a loop. A transaction may issue 100 queries.
dml-in-loopwarninginsert / update / upsert / delete / undelete / merge, or the Database.* form, inside a loop. A transaction may issue 150 DML statements.
async-in-loopwarningSystem.enqueueJob, Database.executeBatch, System.schedule, or an @future method, inside a loop. Each names its own ceiling: 50 Queueable jobs or @future calls per transaction, 5 batch jobs at once, 100 scheduled jobs at once.
callout-in-loopwarningHttp.send or Messaging.sendEmail inside a loop. A transaction may make 100 callouts and 10 email sends.
hardcoded-idwarningA genuine Salesforce record Id written as a literal in production code. Ids differ between orgs.
empty-catchwarningA catch block with no rethrow and no log. A comment does not reach the log.
see-all-datawarning@isTest(seeAllData=true). The result depends on whatever the org happens to hold.
test-class-not-annotatedwarningA class that declares @IsTest methods with no class-level @IsTest. It deploys, nothing in it is collected as a test, and the suite reports green without running it.
test-without-assertionwarningA test method that asserts nothing. It passes whenever nothing throws, while still counting towards coverage.
debug-statementinfoA System.debug left in production code. Its argument is built on every execution whether or not a log is being captured.

What the rules refuse to flag

A rule that fires on correct code is worse than a rule that does not exist, so each one is measured against ~23,000 Apex files from about forty real open-source Salesforce repositories before it ships. The whole corpus produces 894 findings, and the exclusions are as deliberate as the rules:

  • for (Account a : [SELECT ...]) evaluates its query once, on entry. It is the bulk-safe idiom, not the defect, and it is the first false positive a naive implementation produces.
  • for (Account[] batch : [SELECT ...]) is handed 200 records at a time precisely so the body can do one DML per batch, sodml-in-loop stays quiet inside it.
  • A statement followed by an unconditional break orreturn runs at most once, so it is not one per iteration.
  • hardcoded-id verifies the real 18-character checksum, or a key prefix the bundled describes actually list. AccountNumber12 is the right length and the right character set and is not an Id.
  • empty-catch, hardcoded-id anddebug-statement do not judge test code: an empty catch afterAssert.fail() is the success path, a fabricated Id is how a unit test avoids a DML, and a debug in a test is a diagnostic aid in code that never runs in production. The loop rules do judge tests, because a test transaction has the same 100 queries as any other.
  • test-without-assertion counts a call to a same-class helper that asserts as asserting, so a suite whose twenty tests all delegate to one exerciseHandler method is judged on what that method does. Anything whose name mentions assert or verifycounts too — ApexMocks states its expectations that way.
  • test-class-not-annotated reads the methods, not the class name. A class called …Test that declares no test method has nothing an annotation would make run, and the platform’s ownSystem.Test stub is not a broken suite.
FlagDefaultDescription
[pattern]*Class-name glob, path prefix, or a single .cls / .trigger file. Omitted, it auto-scopes to the package subdirectory you are in.
--severitywarningReport and gate on findings at this level or above: error, warning, info.
--disable-Turn one rule off by id, e.g. --disable empty-catch (repeatable). An unknown id is an error, not a no-op.
--rulesfalseList every rule with its severity and what it protects against, then exit.
-e, --exclude-Exclude paths matching a substring or glob — ** spans directories (repeatable).
--jsonfalseEmit the report on schema nimbus.lint/v1. The run block is present only when a recording was read, so a consumer can tell an evidenced report from a conditional one.

Exit status is 1 when any finding survives the severity threshold, so nimbus lint can gate a build; 2means the command could not run. Files that do not parse are skipped rather than reported — judging a half-built tree is how a linter invents defects — and the count of skipped files appears in the summary.

The same rules appear in the editor as the nimbus/lint diagnostic family, with the rule id as the diagnostic code so you can mute one rule without muting Nimbus. They read the same recording, so the squiggle carries the same counts. Turn them off withnimbus.diagnostics.lint: false. They ship no quick fixes on purpose: flipping seeAllData to false hands back a broken suite, “log the exception” cannot know which logger the codebase uses, and the loop rules need a refactor rather than a text edit.

lint reads every class in the project, so it resolves an@future method called through another class. The editor, which cannot promise a current project index, deliberately under-reports that one case.

nimbus exec

Execute anonymous Apex - like Salesforce's "Execute Anonymous", but locally.

bash
# From a file
nimbus exec script.apex

# Inline expression (auto-wrapped in System.debug)
nimbus exec -c "Calculator.add(1, 2)"
nimbus exec -c "new Account(Name = 'Test').Name"

# Multi-statement inline
nimbus exec -c "Integer x = 5; Integer y = 10; System.debug(x + y);"

# Ends in a statement, so it runs as written - nothing is wrapped
nimbus exec -c "Account a = new Account(Name = 'T'); insert a;"

-c wraps the code in System.debug() only when it ends in a bare expression. Anything ending in a semicolon or a closing brace is already executable and runs verbatim.

Records written by an exec are rolled back when it finishes, so a run is side-effect-free by default. This differs from the org, where executeAnonymous commits. Pass --persist to keep what the snippet writes — the same thing the IDE console's "Keep records" checkbox does, and the way to seed local data from a script.

bash
# Seed data that outlives the run
nimbus exec --persist seed.apex

Running it in the org

--in-org sends the snippet to the Salesforce org named by -o instead of running it locally. The org compiles it, runs it and commits whatever it writes — which is why --persist, --mocks and --mock are refused there rather than ignored.

The run saves the org's debug log under .nimbus/logs/ and converts it into a trace under .nimbus/traces/, so a snippet run in an org can be stepped through afterwards exactly like a local test. Tracing is on by default with --in-org: the log comes back with the response and the org will not produce it again. --trace=false turns it off.

bash
# Run it in the org and record a log you can step through
nimbus exec --in-org -o acme-dev script.apex

# Then replay it
nimbus log replay .nimbus/logs/anon-20260915-233018.log

# The same thing as a JSON document, for a script
nimbus exec --in-org -o acme-dev --json -c "System.debug(Limits.getLimitQueries());"
FlagDefaultDescription
-c, --code-Inline Apex code to execute
--persistfalseKeep records written by this run (writes to the project's local database; refused with --in-org)
--in-orgfalseExecute the snippet in the Salesforce org named by -o instead of locally, and record a replayable trace of it
--tracefalseRecord a replayable execution trace. On by default with --in-org
--jsonfalseEmit the run, the saved log and the trace as JSON instead of human-readable text

nimbus run-method

Call one method of one class and report what it returned. Where nimbus exec runs a block of Apex you wrote, run-method takes a method that already exists and generates the invocation for you — including constructing the receiver, so an instance method needs no new of your own.

bash
# Static method, no arguments
nimbus run-method AccountService.createDefault

# Arguments are Apex expressions, one --param each
nimbus run-method Calculator.add --param a=2 --param b=3

# Note the inner quotes: the value is Apex source, not a literal
nimbus run-method AccountService.byName --param name="'Acme'"

# Structured result, including per-line variable snapshots
nimbus run-method AccountService.createDefault --json

Whether the method is static and what it returns are read from your project's source, so the argument is just Class.method — the last dot splits it, which keeps an inner class's qualified name intact (Outer.Inner.method).

Because a --param value is Apex source rather than a string literal, it can be anything the parameter accepts: a list, a new Account(Name = 'Acme'), or a call to a factory. Only the first = separates, so a value that is itself an assignment or a map literal survives intact.

Flags

FlagDefaultDescription
--param-Argument as name=<apex expression>. Repeatable; pass them in declaration order.
--jsonfalseEmit the structured result instead of the human summary
--staticread from sourceOverride static detection: true or false
--return-typeread from sourceOverride the return type read from source. Use void to discard the result.

The two overrides exist for a method nimbus cannot resolve — one that lives in a managed-package stub, say. Resolution failure is an error on its own; pass both --static and --return-type and the call goes ahead anyway.

Output

The default output is the return value with its Apex type, any System.debug lines with the file and line that produced them, a count of the lines that executed and the files they were in, and the duration. A method that threw exits non-zero and prints the failure.

--json adds what the terminal cannot usefully show: variables carries a per-line snapshot of every local in scope (file, line, and each variable's name, value and type), and executedLines maps each file to the lines that ran. This is the same document the VS Code and JetBrains plugins render as inline annotations — the editors' "Run method" code lens and this command are one implementation.

The method runs against the local database with the same configuration nimbus exec uses — seeded rows, org defaults, and managed-package stub namespaces all present — but inside the same isolating transaction a test gets, and that transaction is rolled back when the command exits. Its DML does not survive the run, which is the difference between it and nimbus exec: you can call a method that inserts records without leaving them behind.

nimbus compare

Run the same thing locally and on a connected org, then diff the two results structurally. Three inputs: a snippet of anonymous Apex (-c), a SOQL query (-q), or a whole test selection (--tests Pro). For the first two the verdict leads: identical, different, or which side errored, and both stay free. Comparing a whole test run against the org is the Pro form.

bash
# Compare a computation
nimbus compare -c "System.debug(Decimal.valueOf('1.5').round());"

# Compare a query against a specific org
nimbus compare -q "SELECT Name FROM Profile WHERE Name = 'System Administrator'" -o my-sandbox

# Compare a whole test class, then the whole suite
nimbus compare --tests AccountServiceTest -o my-sandbox
nimbus compare --tests "*" -o my-sandbox

# Machine-readable result
nimbus compare -c "System.debug(1+1);" --json

What can never match is normalized out and every normalization is reported: record Ids (org and local Ids never agree, so Id values are excluded from comparison), audit fields (CreatedDate and friends), row order when the query has no ORDER BY (with one, order is part of the contract and is compared), and number/datetime formatting. Differences are typed — row counts, cells, columns, output lines, exceptions — and classified with a confidence: data, schema, engine, nondeterminism, or unknown.

The local side always rolls back — a compare never writes to the local database. The org side is a real org call, so anonymous Apex really executes in the org. A sanitized repro is written under .nimbus/differential/<timestamp>/.

Comparing a test run Pro

--tests runs the selection on both sides and reports a verdict per test method:

  • match — both sides agree. If both failed, the normalized messages agree too.
  • divergence — the two sides reached different outcomes. This is the one to look at.
  • message-divergence — both failed, with different messages. Softer on purpose: Nimbus and Salesforce word the same assertion failure differently, so this bucket is expected to be noisy and is counted apart.
  • org-only-error — the org produced no trustworthy outcome for that test (a class it could not compile, a run that never finished).
  • missing-in-org / missing-in-local — one side never ran it.

An org test run can fail for reasons that say nothing about your code: an expired session, an exhausted API budget, a run still queued when Nimbus stops waiting. None of those is ever reported as a divergence. They are surfaced as environmental failures, in their own section, above the counts.

The org side is a real org test run: it takes as long as one normally does, and any data those tests create is created in the org. It runs through the same native Tooling API path nimbus test --in-org uses, and reports each method as the org finishes it. Unless you pass --trace, it never touches the org's trace flags or debug levels — a comparison should not reconfigure your debug logging to answer a question about test outcomes. Failure messages are normalized before comparison — record Ids, durations, timestamps, whitespace — and every normalization is reported.

The selection has to be one the org can be asked for: *, a class name, a Class.method, or a comma-separated list of those. A wildcard inside a name (Account*Test) is refused rather than guessed at — running two different sets of tests and calling the difference a divergence would be worse than not answering.

The repro report

When tests disagree, a sanitized repro is written under .nimbus/differential/testrun_<timestamp>/ — the same pattern the snippet modes follow. It records what ran, both sides for the divergent methods only, the normalizations applied, the environmental block verbatim, and the org test run id so the run can be found again in the org. The org identity is masked to its alias; a full username is replaced outright, because a masked local part with an intact domain still names the org. A run where the two sides agreed writes nothing — there is nothing to reproduce. --no-report turns it off, and the path is printed when one is written.

Comparing coverage

--coverage adds a second table: each class's covered percentage on both sides and the gap between them, biggest gap first. The local side is the same collection nimbus test --coverage uses; the org side is the org's own ApexCodeCoverageAggregate table, one row per class or trigger it tracks. Percentages are recomputed from line counts on both sides, because the org's own figure is an already-rounded integer.

Coverage is a table beside the verdicts and never one of them. The two engines instrument different lines, so a gap is a fact to look at — not a disagreement about behaviour, and never a divergence. A side that could not report coverage says so and is never shown as 0%: this class has no coverage and nobody told us about this class are different findings. Classes only one side reported are listed after the table without a delta, and every exclusion the comparison applied is named.

Comparing governor usage

Every comparison reports what the local side spent on each governor limit — SOQL queries, DML statements, CPU time, heap — per test method, and records a replayable trace under .nimbus/traces/ you can open with nimbus trace timeline <run-id>. That costs your machine and nothing else, so it is always on.

--trace asks the org for the same thing. The org's numbers come from a debug log per test method, and recording one means taking the running user's single USER_DEBUG trace flag for the length of the run — so it is opt-in by name. The flag is restored afterwards, on the failure and cancellation paths as well as on the success one. An org that will not provision logging does not fail the comparison: the verdicts are unaffected and the org's governor columns read not recorded.

A side that recorded nothing is never shown as a zero. This test used no queries and nobody measured this test are different findings, and the second printed as the first sends you hunting a difference that was never measured. Governor usage is a column beside the verdicts and never one of them.

--tests "*" asks the org for RunLocalTests, which is every unmanaged test in the org — a superset of this project's suite whenever the org carries classes you don't. Those are reported as missing-in-local and counted separately, not as divergences. Naming the classes is the precise way to ask.

FlagDefaultDescription
-c, --code-Anonymous Apex to run on both sides
-q, --query-SOQL SELECT to run on both sides
--tests-Pro. Test selection to run on both sides ("*", "AccountTest", "AccountTest.testInsert", or a comma-separated list)
--org-timeout30m0sHow long to wait for the org test run before giving up (--tests only)
--coveragefalseAlso compare per-class code coverage on both sides (--tests only)
--tracefalseAlso record a debug log per method in the org, for its governor numbers and a replayable trace (--tests only). Takes the running user’s USER_DEBUG trace flag for the run and restores it afterwards
--no-reportfalseDon’t write a repro report when tests disagree (--tests only)
--jsonfalseEmit the full comparison result as JSON

nimbus fixture

Feature overview: test data.

Generate a TestDataFactory with a create<SObject>() method per object, with required fields pre-filled from the local schema. Standard objects work with zero sync (Nimbus ships their describes); custom objects need a sync. Prints to stdout by default.

bash
nimbus fixture Account Contact          # print a TestDataFactory to stdout
nimbus fixture My_Object__c --write     # save it to the default package

Required lookups are generated, not left as TODOs

When an object has a required lookup, Nimbus follows it: the parent object is added to the factory automatically (even if you did not name it), methods are emitted parents-first so they can actually be inserted in order, and a create<SObject>WithParents() method inserts the parents and wires the child to them.

bash
$ nimbus fixture LoyaltyTransaction__c

    static Account createAccount() { ... }

    static LoyaltyTransaction__c createLoyaltyTransaction__c() {
        return new LoyaltyTransaction__c(
            Points__c = 1,
            Reason__c = 'Purchase'
        );
    }

    // Inserts the required parents, then returns an un-inserted record wired to them.
    static LoyaltyTransaction__c createLoyaltyTransaction__cWithParents() {
        Account parent0 = createAccount();
        insert parent0;
        LoyaltyTransaction__c record = createLoyaltyTransaction__c();
        record.Account__c = parent0.Id;
        return record;
    }

A required lookup to the object's own type has no root to generate from, so it becomes an explicit TODO rather than infinite recursion. Lookup cycles between two objects are reported as a warning instead of being silently broken. A required parent with no local schema also becomes a named TODO (plus a warning to run nimbus sync) — never a call to a create method that was not generated, which would produce a factory that does not compile.

Values the schema vouches for

Picklist fields use a real value from the field's value set, not a placeholder — a restricted picklist rejects anything else, so a fabricated value produces a factory that always fails on insert. When the local schema has no value set, the field is marked unresolved rather than guessed. The generated class is deterministic: the same project always produces the same file, so it diffs cleanly.

FlagDefaultDescription
--classTestDataFactoryGenerated class name.
--writefalseWrite the class to the default package instead of stdout.
--api-version62.0API version for the -meta.xml when --write.

nimbus coverage diff

Compare two JSON coverage reports (from nimbus test --coverage-report cov.json) and report the overall coverage delta, per-file regressions and improvements, and the exact lines that went from covered to uncovered. Built for PR coverage gates.

bash
# baseline on main, then the branch:
nimbus test "*" --coverage-report base.json
nimbus test "*" --coverage-report pr.json
nimbus coverage diff base.json pr.json --fail-on-drop
FlagDefaultDescription
--fail-on-dropfalseExit non-zero when overall coverage decreased (CI gate).

nimbus coverage lines

What a line cost, not whether it ran. A coverage report says line 42 was executed. This says line 42 ran 47 times, issued 47 SOQL queries — 47 of the 100 a transaction gets — and spent 1.2 seconds in the database, and that AccountServiceTest.testBulk is the test that did it.

The counts are the governor's own: a query is attributed to a line from the same call that charges it against Limits.getQueries(), so the per-line numbers and the transaction's usage cannot disagree. Record with --evidence, read with this.

bash
nimbus test "*" --evidence
nimbus coverage lines AccountService
nimbus coverage lines force-app/main/default/classes/AccountService.cls --all
nimbus coverage lines AccountService --json

By default only lines that issued a query or wrote a row are listed — those are the lines a limit is spent on. Database time is attributed to the outermost line that opened the operation, so an insert accounts; reports the time its triggers spent too; counts are not rolled up that way, so a query inside a trigger is counted on the trigger's own line.

EXEC, SOQL, DML and DB TIME are totals across the whole run — the sum over every test that reached the line. PEAK/TX is the most any single transaction charged the line, and it is the only column a per-transaction governor ceiling may be read against; TEST names the test that peak transaction belonged to.

FlagDefaultDescription
--allfalseList every line the run reached, not only the ones that queried or wrote.
--jsonfalseEmit the per-line evidence as JSON (schema: nimbus.lineevidence/v1).

nimbus budget Pro

Per-test ceilings on the governor counters, enforced as a CI regression gate — a performance budget for Apex. The platform's own limits are a cliff at 100 SOQL and 150 DML: a test sits at 94 queries for a year, someone adds a loop, and the failure arrives in production. A budget is the line you draw well below that cliff and defend on every pull request.

Five counters are budgetable: soql, soql_rows, dml, dml_rows and cpu_ms. All five are machine-independent. Four are exact counts; cpu_ms is Nimbus's virtual CPU clock, which counts operations rather than wall time — the same test produces the same number on a laptop and on a CI runner, which is what makes a CPU budget checkable at all.

bash
nimbus budget init            # measure the suite, write nimbus.budgets.json
nimbus budget check           # re-measure, exit 1 if any test is over budget
nimbus budget check --json    # machine-readable result for CI

The budget file

nimbus.budgets.json at the project root. nimbus budget init writes it; it is meant to be committed and read in code review.

json
{
  "version": 1,
  "defaults": {
    "soql": 50,
    "dml": 30
  },
  "tests": {
    "OrderServiceTest.*":             { "soql": 20, "dml_rows": 60 },
    "OrderServiceTest.testBulkOrder": { "soql": 5 }
  }
}

Keys under tests use the same wildcard syntax as nimbus test <pattern>, matched against ClassName.methodName. An unknown counter name is a hard error, not a silently ignored key.

Which budget applies

Resolution is per counter and layered, most specific first:

  1. Every tests pattern matching the test, ordered by specificity: an exact test name (no *) beats any wildcard; otherwise the pattern with more literal characters wins; ties break on the pattern string ascending.
  2. The defaults block.

The first level that names the counter wins, and counters it does not name keep falling through. So OrderServiceTest.testBulkOrder above takes soql: 5 from its exact entry and dml_rows: 60 from the class wildcard. A counter no level names is unbudgeted for that test and is never reported. Every violation line names the key its budget came from, so you know which line of the file to edit.

Adopting budgets

nimbus budget init runs the suite and writes measured usage plus headroom.defaults gets the suite's worst observed value per counter — the ceiling a newly written test must stay under — and every measured test gets an entry at its own usage plus headroom. The per-test entries are what actually catch a regression: a single test doubling its queries would still sit far below the suite-wide default. Derived budgets are raised to a small per-counter floor, so a test that issues one query is not broken by its second, and clamped to the platform limit, so no budget is written that could never fire.

What is measured

A test's usage is what Limits reports when the test method ends. Test.startTest() resets the governor counters and Test.stopTest() restores them — platform behaviour, which Nimbus reproduces exactly — so work inside that block is not counted, and a budget on such a test covers only what it does outside the block. Both commands say so out loud when they see it, rather than letting a suite look green while budgeting nothing.

Tests that fail or are skipped are reported but not judged: a test that threw on its third line stopped consuming counters there, so its usage says nothing about what it costs when it works.

Exit codes

FlagDefaultDescription
0Every measured test is within budget.
1At least one budget was exceeded. This is the CI gate.
2Configuration problem: no budget file, an invalid one, or a run in which nothing could be measured.

nimbus budget check flags

FlagDefaultDescription
--pattern*Tests to measure (same syntax as nimbus test).
--filenimbus.budgets.jsonBudget file path.
--jsonfalseEmit the result as JSON instead of the human report.
-p, --parallelnimbus.test.parallelTest workers. The counters are deterministic, so this affects speed only.

nimbus budget init flags

FlagDefaultDescription
--pattern*Tests to measure (same syntax as nimbus test).
--filenimbus.budgets.jsonBudget file path to write.
--headroom20Percent of slack to leave above measured usage.
--defaults-onlyfalseWrite only the suite-wide defaults block, no per-test entries.
--forcefalseOverwrite an existing budget file.
-p, --parallelnimbus.test.parallelTest workers.

nimbus agent (experimental)

Test an Agentforce agent's action surface locally — the actions, never the planner. nimbus agent list reads the agent's declared actions from your source (both GenAi metadata generations and Agent Script) and shows how each resolves against this project; nimbus agent test executes them through the local runtime with per-action results, governor cost, and rollback by default. An action whose declared inputs don't match its Apex request wrapper is refused by name — a correspondence nothing org-side checks.

nimbus agent list --coverage answers the other question: which actions has no local test reached? Each action is marked observed — a coverage run recorded a test executing its target's file — or static, meaning no run recorded anything and a test merely reaches the target through source references. The two are never merged into one "covered" column: one is a measurement and the other is a reading, and only the first is evidence. An action nothing reaches at all is marked NONE; one with no local target is marked n/a rather than uncovered, because nothing here could ever cover it. Reaching a target is also not the same as testing the action — the input contract is nimbus agent list's job and executing it is nimbus agent test's.

bash
nimbus agent list                      # every action, and how each one resolves
nimbus agent list --coverage           # ...and what no local test has reached

# One action, with the inputs the planner would have supplied at runtime.
nimbus agent test Support_Agent --action Submit_Case --template
nimbus agent test Support_Agent --params inputs.json

Experimental: the shape of these files and flags may still change.

What nimbus will not test

This list is the shape of the product, not a backlog. Read it before the rest of the section, because everything below is only worth as much as this is honest.

Not testedWhy not
The plannerNo LLM call, no prompt evaluation, no topic selection, no conversation. Which action fires for an utterance belongs to Salesforce's stochastic runtime, and a local verdict on it would be unfalsifiable — you could not tell whether nimbus was wrong or the model rolled differently. Nimbus tests the actions, never the planner.
Standard and platform actionsRecord CRUD, knowledge lookups, draft email. Your repository does not contain them. An action a topic references with no GenAiFunction file in the project is listed and refused, never skipped quietly.
External Services and API actionsThe spec is local, the endpoint is not. Refused by name — unsupported-target-type: nimbus executes apex:// and flow:// only.
Prompt templatesAn LLM call by definition, and nimbus calls no LLM.
Custom Lightning TypesA c__caseInput parameter's shape lives in a Lightning Type Bundle nimbus does not read. --template emits an empty object there and says so, rather than inventing fields.
Example inputsAgent metadata carries no example values, anywhere. That is a platform fact rather than a gap, and it is why fixtures exist — see below.

Conversation fixtures

Agentforce metadata declares an action's input shape and never an example value — the planner fills inputs from the conversation at runtime, so there is nothing on disk to replay. A fixture file is where those inputs live, which is what turns a one-shot CLI invocation into something a pull request can be red about.

bash
# agent-tests/support-agent.agenttest.yaml
schema: nimbus.agenttest/v1
agent: Support_Agent

actions:
  Submit_Case:
    cases:
      - name: high priority escalates and opens a case
        inputs:
          subject: Printer is on fire
          priority: High
        expect:
          result:
            - path: $[0].escalated
              equals: true
          debug:
            - contains: "subject=Printer is on fire"
          db:
            - soql: SELECT Id FROM Case WHERE Subject = 'Printer is on fire'
              count: 1
          governor:
            maxSoql: 2
            maxDml: 1

  # A refusal is not a failure. Pinning the code makes it a regression test.
  Mismatched_Action:
    cases:
      - name: an input the request wrapper does not carry
        expect:
          outcome: refused
          refusal: apex-input-not-on-request-wrapper
bash
nimbus agent test --fixtures                     # every **/*.agenttest.yaml
nimbus agent test --fixtures ./agent-tests      # one directory
nimbus agent test --fixtures --case "Submit_Case/high priority escalates"
nimbus agent test --fixtures --results-xml results.xml --coverage-report cov.json

Every case runs in its own transaction and is rolled back — nothing a fixture does is ever committed. Failures print first, each naming every assertion that did not hold and the exact command that reruns that one case.

What a case can assert

KeyAsserts
resultThe return payload, by path. One of equals, contains, matches, exists, type per entry.
debugSystem.debug output. Passes when any line matches.
dbYour own SOQL, run inside the action's transaction before the rollback. One of count, min, max, exists.
governorCost ceilings: maxSoql, maxSoqlRows, maxDml, maxDmlRows, maxCpu.
outcomepass (default), fail (the action throws), refused (the resolver declines it), resolves (checked against local source, not run).

result paths are a deliberately small JSONPath subset — $, $.field, $.a.b, $[0], $[0].field. Wildcards, recursive descent, filters and slices are refused by name: each matches a setrather than a value, and an assertion whose subject is "some element, possibly none" passes for reasons you did not write down.

Governor limits are ceilings, not equalities. An assertion that a method issues exactly three queries breaks on every unrelated refactor; one that it issues no more than three catches the loop that turned into an N+1.

Database assertions have to run inside the action's own transaction, because the writes are never committed — a query issued anywhere else would see an empty database and every one of them would pass by reporting zero. Your SOQL is run verbatim, never rewritten: a rewritten query is a different assertion from the one in the file.

Importing a Salesforce test-spec

If you already maintain a sf agent generate test-spec YAML, a fixture can read it. Nimbus maps the one thing in it that can be checked locally — the actions the planner is expected to reach — and refuses the rest by name, printed on every run.

bash
schema: nimbus.agenttest/v1
import: ./support-agent-test-spec.yaml

Each named action becomes a case that checks it against local source without running it: the target still exists, and its declared inputs are still fields on the request wrapper. That second check is the one no other tool in the ecosystem makes — the org validates that invocationTarget names something in the org; it cannot tell you whether the action's inputs still match the Apex in this working copy. The spec carries no input values, so nimbus does not invent any: a fabricated input set produces a green case that tested a value nobody chose.

Spec expectationNot mapped, because
utteranceThe planner's input. Nimbus does not select actions from natural language.
expectedTopic, topic_sequence_matchTopic selection is the planner's, and stochastic. A local verdict would be unfalsifiable.
expectedOutcome, bot_response_ratingNatural language scored by an LLM judge. Nimbus calls no LLM.
coherence, conciseness, completeness, factuality, instruction_followingLLM-judged quality metrics.
output_safety, output_pii_leakageLLM-judged safety metrics.
output_latency_millisecondsA timing of Salesforce's hosted runtime. A local action's duration is a different measurement.
contextVariablesConversation context from the running session. Give the action its values with inputs: instead.
subjectVersionPins an activated agent version in the org. A local run tests the source in this working copy.

Those rows are the boundary above, applied key by key: every planner-side expectation is refused by name and printed on the run, never approximated into a local verdict.

CI

A fixture run emits the same JUnit XML and coverage reports a test run does, through the same writers — so anything that already consumes a nimbus report consumes this unchanged. The GitHub Action picks fixtures up automatically and adds one section to the same PR comment, failures first. A repository with no fixtures pays nothing and the comment says nothing about agents.

bash
- uses: nimbus-solution/nimbus/.github/actions/nimbus-test@v1
  with:
    license-key: ${{ secrets.NIMBUS_LICENSE_KEY }}
    agent-fixtures: auto        # or a path, or false

Flags

FlagDefaultDescription
--fixturesRun conversation fixtures. Bare, it searches the project for **/*.agenttest.yaml; give a file or directory to narrow it.
--caseRun only cases whose "Action/Case name" contains this text. This is what the reproduce line in a failure uses.
--actionSingle-action mode: run just this action with --params.
--paramsJSON file holding one action's inputs, or an object keyed by action name.
--templatefalseWrite a fill-in-the-blanks input template from the declared schema instead of running.
--results-xmlPath to save JUnit XML for the fixture run (Pro).
--coveragefalseCollect code coverage across the fixture run.
--coverage-reportPath to save the coverage report; .json, .xml (Cobertura) and .html are detected from the extension. Implies --coverage. XML is Pro.
--jsonfalseEmit the run as JSON (schema nimbus.agenttest-run/v1).
--tracefalseRecord an execution trace for each action.
--trace-output.nimbus/tracesDirectory for trace output files.

nimbus mutate Pro

Run mutation testing against your Apex code. Nimbus introduces small changes (mutations) to your production code - flipping operators, negating conditions, changing return values - then runs your tests against each mutant. If your tests catch the change, the mutant is "killed." If all tests still pass, the mutant "survived," revealing a gap in your test suite.

This is a technique widely used in Java (PITest), JavaScript (Stryker), C# (Stryker.NET), and Python (mutmut) - but has never been possible for Apex because org-based test execution is too slow. Nimbus runs tests locally in milliseconds, making hundreds of mutations feasible in seconds.

The positional pattern selects which production classes to mutate. If it resolves only to test classes (e.g. AccountServiceTest), Nimbus mutates the class under test instead — derived by stripping conventional Test prefixes/suffixes — and runs that test class against each mutant. This makes nimbus mutate AccountServiceTest answer the natural question: "is this test suite actually any good?"

A baseline run comes first, because a test that was already failing makes every mutant look killed. When you name a target class and do not pin --test, Nimbus scopes the per-mutant run to the tests the baseline saw execute that class, and only those have to be green — an unrelated red class elsewhere in the project no longer blocks the run. If one of the covering tests is red, Nimbus refuses and names them.

bash
# Mutate all production classes, run all tests
nimbus mutate

# Mutate a specific class
nimbus mutate AccountService

# Pass a test class — Nimbus mutates the class under test and runs that test
nimbus mutate AccountServiceTest

# Mutate a class, only run specific tests per mutant
nimbus mutate --class AccountService --test AccountServiceTest

# Increase per-mutant timeout to 60 seconds
nimbus mutate --timeout 60

# Only print surviving mutants during the run (kills suppressed)
nimbus mutate --survivors-only

# Fail the run (exit code 1) if mutation score is below 80%
nimbus mutate --min-score 80

# Emit a machine-readable JSON report for CI (exit code still set by --min-score)
nimbus mutate --json --min-score 80

Mutation Types

TypeExampleWhat it tests
Arithmetic+ → -, * → /Your tests verify calculation results, not just that code runs
Comparison< → <=, > → >=Boundary conditions are checked
Negate conditional== → !=, < → >=Conditional branches are actually tested
Logical&& → ||Compound conditions aren't over-simplified
Boolean literaltrue → falseBoolean flags affect behavior
Increment/decrement++ → --Loop and counter logic is validated
Negate prefixRemove !Negation logic is tested
Return valuereturn x → return nullReturn values are used by callers
Integer literalN → N+1Off-by-one and boundary constants are asserted, not just threaded through
String literal"foo" → ""String content is asserted, not just presence (SOQL fragments and sObject type names are skipped)
DML verbinsert → update, delete → undeleteThe right persistence operation is being invoked, not just any DML
Call removalservice.doThing(); → no-opSide-effecting calls are asserted (assertion helpers on System/Assert/Test are skipped to keep the score honest)
Loop boundaryi < ni < n + 1 / i < n - 1Loops iterate the intended number of times — catches off-by-one against variable bounds, which integer ±1 and boundary swaps miss

Flags

FlagDefaultDescription
--class-Only mutate this class (default: all non-test classes)
--test*Test pattern to run against each mutant
--timeout30Per-mutant timeout in seconds
--survivors-onlyfalseOnly print surviving mutants during the run (kills, timeouts, and errors are suppressed). The final summary still lists totals and surviving mutants.
--min-score0Exit with code 1 if the final mutation score is below this threshold (0-100). Useful for CI gating.
--jsonfalseEmit a single machine-readable JSON report to stdout (mutationScore, total, killed, survived, timedOut, errors, survivors[], mutants[]) and suppress the human summary. Combine with --min-score to get both the report and the exit code. This is the format the release mutation gate reads.

The JSON report

mutants carries every evaluated mutant in evaluation order, each with its class, line, mutation, description and a status of killed, survived, timedOut or error. A kill also carries killedBy - the tests that caught it, which is what tells you whether a mutation was caught by the test that was supposed to catch it or by an unrelated one. An error carries the message under error.survivors is unchanged: the actionable subset, the mutations nothing caught.

bash
{
  "mutationScore": 75,
  "total": 4,
  "killed": 3,
  "survived": 1,
  "timedOut": 0,
  "errors": 0,
  "survivors": [
    { "class": "Calculator", "line": 20, "mutation": "BooleanLiteral", "description": "Changed boolean true to false" }
  ],
  "mutants": [
    {
      "class": "Calculator", "line": 12, "mutation": "ArithmeticOperator",
      "description": "Changed + to -", "status": "killed",
      "killedBy": ["CalculatorTest.testAdd", "CalculatorTest.testSum"]
    },
    {
      "class": "Calculator", "line": 20, "mutation": "BooleanLiteral",
      "description": "Changed boolean true to false", "status": "survived"
    }
  ]
}

Understanding the Score

The mutation score = killed mutants / total mutants × 100%. A project with 95% line coverage but 40% mutation score has tests that execute code without verifying behavior. Think of it as the difference between "I walked through every room" and "I checked every room for problems."

nimbus fuzz Pro

Property-based testing for trigger paths. Nimbus generates records that are valid per your project's schema - required fields populated, picklist values from the real value set, string lengths within bounds, required lookups satisfied by a generated parent record - and runs each one through the DML pipeline with your triggers firing. Alongside typical values it generates the adversarial ones your triggers actually meet in production: null in every nillable field, empty strings, boundary numbers and dates, unicode.

This is the QuickCheck/Hypothesis technique, applied to triggers. It has never been practical for Apex because an org grants 150 DML statements per transaction and each round-trip costs seconds. Locally, every run executes in its own rolled-back transaction with fresh static state, in milliseconds - hundreds of generated records take seconds.

A validation-rule rejection or an addError is not a failure - that is the object doing its job, and it is tallied separately. A failure is the pipeline surfacing a defect: an unhandled exception inside a trigger, a non-DML exception escaping the DML, or an engine-level error. On the first failure the input is shrunk - fields toward null, strings shorter, numbers toward zero, re-running the pipeline at every step - until only what triggers the bug remains, and reported as a paste-ready Apex snippet for nimbus exec.

bash
# 200 generated inserts through the Account trigger pipeline
nimbus fuzz Account

# More runs - same milliseconds each
nimbus fuzz Account --runs 1000

# Also update each record after a clean insert (before/after update paths)
nimbus fuzz Account --update

# Replay a reported run exactly
nimbus fuzz Account --seed 741253

# Machine-readable report
nimbus fuzz Case --json

A found failure reports the minimal reproducing input:

bash
FAILURE on run 8 of 200 (seed 741253), insert phase
  UNKNOWN_EXCEPTION: Insert failed. First exception on row 0; first error:
  UNKNOWN_EXCEPTION, Attempt to de-reference a null object: []

Minimal failing record:
  Name = 'a'
  Rating = 'Hot'

Repro (paste into `nimbus exec`):
  Account rec = new Account(Name = 'a', Rating = 'Hot');
  insert rec;

Shrunk in 26 steps (32 executions). Replay this run: --seed 741253

Flags

FlagDefaultDescription
--runs200Number of generated records to run
--seedrandomSeed for deterministic replay. Every report prints the seed it used; passing it back replays the identical sequence, including the failure and its shrinking.
--updatefalseAfter each clean insert, also update the record with newly generated values, exercising the before/after update trigger paths
--jsonfalseEmit the full report as JSON: pass/rejection tallies by DML status code, average per-run cost, and the shrunk failure with both the minimal and the original repro

Exit code is non-zero when a failure is found, so nimbus fuzz can gate CI. Deliberate rejections are tallied by DML status code in the report - a large REQUIRED_FIELD_MISSING count means generated records are missing a constraint, not that your triggers are healthy.

nimbus explain

Feature overview: failure intelligence.

Explain one test's outcome in full: the exception, the source location, the assertion's expected and actual values, and the SOQL and DML executed just before it failed.nimbus test output stays terse - this is the explicit expansion, so nothing about a normal run changes.

bash
# Explain a specific failure
nimbus explain AccountServiceTest.testInsertAccount

# Structured output for scripts and agents
nimbus explain AccountServiceTest.testInsertAccount --json

# Mask runtime values before pasting into an issue or chat
nimbus explain AccountServiceTest.testInsertAccount --redact

# What does this command keep, and where?
nimbus explain --retention-policy

What it prints

bash
ZZDiagFailTest.assertionFailsWithOperationTail — FAILED (8ms)

  System.AssertException
  Assertion failed: expected 999, got 1. diagnostics fixture: expected 999 accounts

  at force-app/main/default/classes/ZZDiagFailTest.cls:23

  Assertion
    expected  999
    actual    1

  Last operations before the failure (most recent last)
    dml   insert Account  (1 row)  ZZDiagFailTest.cls:18
    soql  SELECT Id, Name FROM Account WHERE Name = 'Diagnostics Fixture Co'  ZZDiagFailTest.cls:20

  Not established by this explanation
    - Explanation is derived from local execution only; the org remains the final authority.
    - Record-level mutation provenance is not recorded.
    - No comparison against a previous passing run is available.

Last passed

When history holds a run in which the test passed, the report says when — and whether the Apex source has changed since. An unchanged fingerprint means the code is not what differs, which separates a regression from non-determinism. It does not close the question: test data, ordering, time, configuration and org metadata all sit outside the fingerprint, and the report says so.

It states what it does not know

Every explanation ends with what it could not establish. Nimbus runs Apex locally at high fidelity, but it is not the Salesforce platform: an absent record in the evidence means "not observed", not "did not happen". The report says so rather than letting you assume otherwise - and it names the boundaries of the feature itself, so an empty operation tail is never mistaken for a test that ran no queries.

Flags

FlagDefaultDescription
--jsonfalseEmit the versioned JSON explanation (schema: nimbus.explain/v1)
--redactfalseMask assertion operands, SOQL string literals, and their occurrences in the exception message
--retention-policyfalsePrint what this command keeps, and where

Retention and redaction

Explanations are computed in-process and are not written to disk, cached, or uploaded. Operands and SOQL text are shown verbatim by default, because the local developer already has that data in front of them. Use --redact for output that leaves the machine; it deliberately over-masks, keeping structure (object, fields, exception type, location) so a redacted report stays diagnosable.

Same contract for agents

The MCP tool explain_failure returns the same versioned payload, built from the same code path, so a human and an agent never read divergent accounts of one failure. It answers from the previous run when possible and otherwise runs just that test.

nimbus triage

Run tests and group the failures by the cause the engine recorded when each one happened. Built for the first run on an unfamiliar codebase, where the output is a wall of failures and the question is "how many distinct problems is this, really?"

bash
# Triage the whole suite
nimbus triage

# Triage one class
nimbus triage "AccountTest.*"

# Structured output (schema: nimbus.triage/v1)
nimbus triage --json

# Show at most 3 tests per group
nimbus triage --limit 3

What it prints

bash
Triage — 133 failed of 2357 tests (*)

  Grouped by engine-assigned cause

    assertion.failed             93 test(s)
      An assertion in the test did not hold
        AccountServiceTest.testCreate
          replay: nimbus trace AccountServiceTest.testCreate --run 2026-08-26T02-08-59_5ba1f24e

    loaddata.resource-not-found  2 test(s)
      Test.loadData referenced a static resource the project does not contain
      fix: Retrieve the static resource, or check the resource name passed to Test.loadData.

  No cause recorded (40) — listed individually, not grouped
    ACCT_IndividualAccounts_TEST.testOwnerLastNameUpdate — Invalid field Owner for Account

  Run-level evidence (2) — not attributed to any failure
    Apex Classes             ABadClass

Each failure carries the command that reopens its recorded trace, the same as nimbus test — see replay on failure. Pass --no-trace-on-failure to skip recording.

It is not a classifier

Every group corresponds to a cause some part of the engine assigned at the moment of failure — a Test.loadData resource that was absent, a @testSetup that threw, a field the schema provider looked up and did not find. Nothing inspects message text to decide where a failure belongs.

Failures the engine could not classify are listed individually, never folded into whichever group looks plausible. A null-pointer exception is a symptom whose cause could be a missing stub, an unpopulated field, or a genuine bug — no pattern match can tell those apart, and a wrong grouping sends you to fix the wrong thing while hiding the failure inside a count.

Run-level evidence

Missing metadata is reported separately and deliberately not attributed to individual failures. Nimbus degrades an absent definition to null or zero rows instead of throwing, so the failure surfaces later, somewhere that no longer knows a schema gap caused it. The gap is real and worth fixing; which test it broke is not something the engine can honestly claim.

Flags

FlagDefaultDescription
--jsonfalseEmit the versioned JSON triage report (schema: nimbus.triage/v1)
--limit5Max tests listed per group (0 = all)

Suite health in doctor

nimbus doctor --suite runs the same triage and appends a ranked "fix in this order" list to doctor's setup checks - largest cause first, so a red suite becomes a short ordered worklist instead of a wall of failures. It is opt-in because it runs the tests; plain nimbus doctor stays fast enough for a pre-flight check. With --json the report appears undersuite_health.

nimbus history

Browse past test runs in an interactive TUI. Every nimbus test run is recorded under .nimbus/history/; this command reads it back — recent runs, their pass/fail breakdowns, and a flaky-test report. Free.

bash
# Browse recent test runs
nimbus history

# Flaky report: same test, same conditions, different outcome
nimbus history --flaky

# Show the last 50 runs
nimbus history --limit 50

Piped output is JSON

When stdout is not a terminal — a pipe, a CI runner — nimbus history skips the TUI and emits the run list as JSON, so nimbus history --limit 50 | jq works without a TTY.

Flags

FlagDefaultDescription
--flakyfalseShow the flaky test report instead of the run list. Applies to the TUI; a piped run always emits the run list.
--limit20Number of recent runs to display

nimbus history get <run-id>

The list carries per-run totals only. Every per-test result — its status, duration and failure message — lives in the stored record, and history get prints it. show is an accepted alias.

bash
# Everything recorded for one run
nimbus history get 1756060500123456789

# The timestamp matches too, and is easier to type
nimbus history get 20260824T1015

# Just the failures
nimbus history get 20260824T1015 --json | jq '.results[] | select(.passed == false)'

The run ID is the id field from nimbus history — a nanosecond timestamp. The store matches it as a substring of the record's file name, <timestamp>_<id>.json, so a leading chunk of the ID works and so does the human-readable timestamp. The output leads with the run's pattern, timing, totals and the environment it ran under (Nimbus version, worker count, isolation mode, whether it was a full run), then one line per test with its failure message.

JSON is the default when stdout is not a terminal, matching the run list — so piping into jq needs no flag. --json forces it when it is.

FlagDefaultDescription
--jsonfalse (true when piped)Emit the full run record as JSON

History also powers nimbus explain's "last passed" comparison and the trend views in traces & analytics.

nimbus graph

Feature overview: dependency graph.

Show which classes reference a class — directly and transitively — and which tests relate to it. A navigation aid for "what does changing this touch?"

bash
nimbus graph                     # project overview: size, cycles, most-depended-upon
nimbus graph AccountService      # what reaches one class
nimbus graph Label.Order_Error   # which classes read one custom label
nimbus graph Handler_Config__mdt # who queries one custom metadata type
nimbus graph SObject.Account     # who reads it, who writes it, what fires
nimbus graph Permission.Can_Approve_Orders  # what is gated on one custom permission
nimbus graph Resource.Test_Accounts         # what loads one static resource
nimbus graph Flow.Order_Automation          # what fires one flow, and what it calls
nimbus graph AccountService --json

Bare nimbus graph prints the orientation view: how many classes, triggers and edges, the cycle count, the custom-label count with how many of them nothing in Apex reads, the number of custom metadata types, how many SObjects the code touches, the custom permissions with how many nothing in Apex checks, the static resources with how many nothing in Apex loads, the flows with how many of them are inactive but still wired to something, and the most-depended-upon classes — the step before you know which class to ask about. With --json it emits the full nimbus.graph/v1 payload instead.

It is not a test-selection decision

This reads syntactic references. We measured that reading against real coverage across five projects and found it unsafe for choosing which tests to skip: a trigger dispatching through custom metadata reaches its handler with no reference to follow, so on one real project it found 1 of the 46 test classes that genuinely exercise a handler. The command says so, and refuses to present itself as a selection tool.

Measured alongside, not merged

When a coverage map exists, the tests that actually executed the class are shown as a separate answer, along with any that no syntactic path reaches — a direct measurement of the gap:

bash
ACCT_IndividualAccounts_TDTM — reachability

  Tests reached syntactically (1)
      ACCT_IndividualAccounts_TEST

  Tests a coverage run actually observed executing it (46)
      ACCT_AdministrativeNameRefresh_TEST
      ...

  Observed but NOT reachable syntactically (45) — the blind spot, measured

  Not established by this view
    - Syntactic references only. A trigger dispatching through custom metadata
      reaches handlers with no reference to follow.
    - This is reachability, not a test-selection decision.

The two are deliberately not combined into one number. Where they disagree, the disagreement is the useful part.

Custom labels

Custom labels are nodes in the graph, and Label.<Name> is a root selector: nimbus graph Label.Order_Error reports the label's value and the classes that read it. Reads spelled out in source — Label.Order_Error, or System.Label.get with a constant name — are found by reading the code. Reads through a computed name are not visible to any syntactic reading; those come from the last coverage run, where the interpreter resolved the name as the read happened, and are listed separately as observed.

Label edges cover Apex only. References from Flows, LWC, Aura and formulas are not read, so unreferenced in the overview line means unreferenced from Apex — it is not a delete list. Dynamic reads carry the freshness caveat that applies to every observed edge: a read on a path the coverage run did not execute is not there, and one recorded before the code changed may no longer happen.

Custom metadata types

Custom metadata types are nodes too, and the API name is the root selector — no prefix to learn, since __mdt is reserved:

bash
$ nimbus graph Handler_Config__mdt
Handler_Config__mdt — custom metadata type
    4 record(s)

  Queried by
    1 directly, 12 transitively
      TDTM_TriggerHandler

  Dispatches to (4) — classes named by record field values
      ACCT_IndividualAccounts_TDTM
      ...

  Tests reached syntactically (1)
      TDTM_TriggerHandler_TEST

  Classes a coverage run observed being dispatched to (4)
      ACCT_IndividualAccounts_TDTM
      ...

Three readings, kept apart. Queried by is read from source: SOQL against the type, getAll, getInstance. Dispatches to is a heuristic — a record's field values are matched against the class registry, and a value that names a class becomes an edge — so it can over-claim (a text field that happens to read like a class name) and under-claim (a handler named by a value the records do not carry). The third is measurement: after a coverage run, Type.forName(...).newInstance() resolved an exact class name while the code ran, and that dispatch is recorded as observed.

This closes the gap the limits above only apologised for. A trigger dispatching through custom metadata used to reach its handler with no reference to follow; AccountTrigger → TDTM_TriggerHandler → Handler_Config__mdt → ACCT_IndividualAccounts_TDTM is now a chain you can walk. The caveat travels with it — the handler side is a guess until a coverage run confirms it.

SObjects

Objects the code queries or writes are nodes as well, and SObject.<Name> is the root selector — the prefix exists because a class may legitimately be called Account:

bash
$ nimbus graph SObject.Account
Account — SObject

  Queried by (19)
      AccountService
      ...

  Written by (19) — these fire its triggers
      AccountService
      ...

  Triggers on this object (1)
      AccountTrigger

  Record-triggered flows on this object (1)
      Probe_Account_Flow

  Tests reached syntactically (20)
      AccountServiceTest
      ...

  Classes a coverage run observed querying it (1)
      DynamicSelectorTest

Read and write are separated because they are different questions. Written by is the one that carries consequences: a write reaches every trigger the project declares on that object, so the object-to-trigger edges are certain rather than inferred — this is where a trigger finally gets inbound edges, and the caveat that they "come from DML, which this reading cannot derive" no longer applies.

Objects named in source — a SOQL FROM clause, a typed variable under insert, X__c.getInstance() — are found by reading the code. Dynamic access is not visible to any syntactic reading: Database.query with a computed string, DML on a generic SObject or a List<SObject>. Those come from the last coverage run, where the SOQL translator and the DML executor knew the concrete object, and are listed separately as observed — which is how an fflib-style selector layer becomes visible at all. The usual freshness caveat applies: access on a path the run did not execute is not there. Readers and writers cover Apex only; Flows, LWC, Aura and integrations touch objects without appearing here.

A bare argument is still resolved as a class first, since that is what it has always meant. When no class answers, __c and __e names resolve to the object silently — the platform reserves those suffixes for schema — and any other bare name gets an error that suggests the SObject. form when an object of that name exists. Nodes exist only for objects this code touches or a trigger runs on; this is not the org's schema.

Custom permissions and static resources

Both are nodes too, both are leaves, and both have their own root selector — Permission.<Name> lists the classes gating on a custom permission, Resource.<Name> the classes loading a static resource:

bash
$ nimbus graph Permission.Can_Approve_Orders
Can_Approve_Orders — custom permission
    "Approve Orders"
    force-app/main/default/customPermissions/Can_Approve_Orders.customPermission-meta.xml

  Checked by
    1 directly, 2 transitively
      ApprovalGate

  Tests reached syntactically (1)
      ApprovalGateTest

  Classes a coverage run observed checking it (1)
      ApprovalGate

$ nimbus graph Resource.Test_Accounts
Test_Accounts — static resource
    text/csv
    force-app/main/default/staticresources/Test_Accounts.resource-meta.xml

  Loaded by
    1 directly, 1 transitively
      ApprovalGateTest

  Classes a coverage run observed loading it (2)
      ApprovalGateTest
      FixtureLoader

  Observed but named nowhere in source (1) — loaded through a computed name
      FixtureLoader

Checks and loads spelled out in source — FeatureManagement.checkPermission with a constant name, Test.loadData or a PageReference('/resource/…') — are found by reading the code. A name assembled at runtime is not visible to any syntactic reading; those come from the last coverage run, where the permission or resource resolved to an exact name as the check or load happened, and are listed separately — with the observed-only ones called out as named nowhere in source.

Only Apex is read, which matters more here than for any other kind. A permission granted through a permission set or a profile, or enforced in a Flow, a formula or a validation rule, is invisible to this view; so is a resource referenced from LWC, Aura, Visualforce or markup. Unchecked and unread in the overview therefore mean from Apex — neither is a delete list. The usual freshness caveat applies to the observed side: a check on a path the coverage run did not execute is not there.

Flows

Nimbus runs record-triggered flows itself, so they are nodes too, and Flow.<Name> is the root selector:

bash
$ nimbus graph Flow.Order_Automation
Order_Automation — flow
    Draft
    force-app/main/default/flows/Order_Automation.flow-meta.xml

  Fired by DML on ParentObj__c
    1 class(es) write that object
      OrderIntakeService

  Calls Apex (1)
      AccountService

  Calls subflows (1)
      Probe_Account_Flow

  Writes objects (1) — these fire their own automation
      ChildObj__c

  Tests reached syntactically (1)
      OrderIntakeServiceTest

A flow is not a leaf. It has edges in both directions: inbound from the object it triggers on, outbound to the Apex actions it invokes, the subflows it runs and the objects its record elements read and write. That completes the DML edge — a write to an object now reaches both its triggers and its record-triggered flows, so SObject.Account lists the flows next to the triggers and the blast radius of an insert includes the automation a flow starts. Flows are also the first non-class nodes that can sit inside a cycle, through a subflow loop or a flow calling a class that writes the flow's own trigger object; --cycles reports such a loop as nodes rather than classes.

Status travels with the node and changes what its edges mean. A Draft, Obsolete or invalid flow still owns every edge above, but it does not run on record changes — those edges describe what it would reach if it were activated. The overview counts inactive-but-connected flows separately for that reason, and the detail view says it outright rather than letting a full-looking graph imply the automation is live.

Edges are read from the flow's own metadata: the object it is triggered on, the Apex actions and subflows it names, and the objects its record elements touch. An action invoked through a name assembled at runtime is not read, and neither is anything a flow reaches through a screen component. Nor is the launch path of a screen flow started from a page, a button, a quick action or a process — such a flow appears with no inbound edge. Being fired by DML is derived rather than referenced (nothing in an org names a record-triggered flow), and whether a particular write meets a given flow's entry conditions is not evaluated here.

Agentforce agents

Where a project ships Agentforce metadata, its agents, topics and actions are nodes too, and each action's edge lands on the Apex class or Flow its invocationTarget names. A class or flow reached that way says so — Reached by agent actions (1) · Support_Agent › Submit Case — which is a caller no reading of Apex can find, because nothing in Apex references an agent back. For an autolaunched flow it is the only inbound edge there is. Which action a planner selects for a given utterance is Salesforce's stochastic runtime and is not modelled at all. An action whose target this project does not ship, or whose target type nimbus does not run, carries no edge and is counted separately — nimbus agent list names which. A project with no agent metadata sees none of this and pays nothing for it.

Circular dependencies

nimbus graph --cycles lists every circular dependency, largest first, with a shortest loop through each — so the output says where to cut rather than only that a cycle exists.

bash
$ nimbus graph --cycles

    8 classes: Advancement_Adapter, Advancement_Info, TDTM_Config, ...
      shortest loop: Advancement_Adapter → Advancement_Info → Advancement_Adapter
      cut any one edge on that loop to break it

Classes that reference themselves are counted separately rather than listed: in Apex that is nearly always a class qualifying its own statics or an inner class, and printing dozens of them buries the finding that matters. Reported cycles are a lower bound — edges invisible to a syntactic reading may close further loops.

Export and visualise

--format dot|mermaid|json exports the graph. Edges only a coverage run revealed are drawn dashed, triggers get a distinct shape, and the limits travel inside the file as comments — an exported diagram outlives the session that explained it. Past 300 nodes the export refuses and tells you how to narrow, rather than emitting an unreadable hairball. Non-class nodes — custom labels, custom metadata types, SObjects, custom permissions, static resources and flows — stay out of the dot and mermaid exports unless --include-metadata is passed (--include-labels adds the labels alone), so that ceiling keeps measuring the class graph; the JSON always carries them. Pointing the command at a metadata root overrides the default, since stripping the node you asked about would draw its neighbourhood with the subject missing.

bash
nimbus graph --format dot | dot -Tsvg -o graph.svg
nimbus graph AccountService --depth 2 --format mermaid

# Raw Mermaid is not meant to be read in a terminal. Write a .md instead and
# open it — VS Code's preview, GitHub and Obsidian all render the diagram.
nimbus graph AccountService --out graph.md

With --out, a .md path wraps the diagram in a Markdown document: a fenced Mermaid block that renders natively, the cycles listed underneath, and the limits as prose rather than as comments — a comment is invisible once rendered, which is exactly when someone is most likely to read the picture as complete.

An interactive view is available in the Dev UI, VS Code (Nimbus: Show Dependency Graph) and IntelliJ, all rendering the same nimbus.graph/v1 payload. It reads like a note-graph tool: hovering a class highlights its direct neighbourhood and fades the rest, clicking pins the highlight, and search filters by name. Test classes and unconnected nodes can be hidden, the layout forces are adjustable, and nodes can be dragged. Names appear on hubs first and fade in as you zoom. In VS Code, double-clicking a node opens the file.

The resting view is classes and triggers only. Custom labels are added from the display panel — drawn as squares, with the label's value and its reader count in the tooltip; a second checkbox adds the labels nothing reads. Searching does not wait for either: a query that matches a label reveals it while the query is active, so a label that exists in source never comes back as no match. A label: prefix restricts the search to labels.

show custom metadata adds the custom metadata types the same way, drawn as diamonds; the tooltip gives the record count, how many classes query the type, and how many classes its records dispatch to. Their outbound edges are the heuristic ones, and the legend says so. Search reaches them while the toggle is off too, and an mdt: prefix restricts the search to types.

show sobjects adds the objects, drawn as triangles; the tooltip gives the reader count, the writer count and how many triggers a write fires, and an object reached only through dynamic access is drawn in the same orange as every other runtime-observed node. Search reaches them with the toggle off, and an obj: prefix restricts the search to objects.

show permissions & resources is one toggle for the two smallest kinds: custom permissions are drawn as pentagons, static resources as tall boxes. The permission tooltip gives the permission's label and how many classes check it, the resource tooltip its content type and how many classes load it, and either is drawn in the runtime-observed orange when nothing in source names it. Both have their own search prefix — perm: and res: — and search reaches them while the toggle is off, like every other kind. These two are also the first metadata nodes that open: they carry a file, so double-clicking one opens its -meta.xml, where double-clicking a label, a type or an object frames its neighbourhood instead.

show flows is a toggle of its own — an org runs hundreds of flows and "which flows are there" is a question worth its own switch. They are drawn as an outline hexagon: the trigger's shape in the trigger's amber, unfilled and a size step smaller, because a flow is automation rather than data and belongs next to triggers rather than next to the squares and triangles. A flow that is not Active is drawn with a dashed outline — its edges exist but it does not run — and the tooltip gives the status, what fires it, how many classes and subflows it calls, and how many objects it writes. A flow: prefix restricts the search to flows, search reaches them while the toggle is off, and double-clicking one opens its .flow-meta.xml.

Flags

FlagDefaultDescription
--cyclesfalseList circular dependencies, largest first
--formatExport the graph: dot, mermaid or json
--depth2With a class name, how many hops of neighbourhood to include
--outWrite to a file instead of stdout; a .md path wraps the diagram so VS Code, GitHub and Obsidian render it
--include-labelsfalseDraw custom-label nodes in the dot and mermaid exports; labels only (JSON always includes them)
--include-metadatafalseDraw every non-class node — custom labels, custom metadata types, SObjects, custom permissions and static resources — in the dot and mermaid exports
--jsonfalseEmit the report as JSON (schema: nimbus.graph/v1)

nimbus log

Read a Salesforce debug log as an execution rather than as text. A log recorded at Apex Code FINEST is a complete record of a transaction — every statement, every variable as it changed, every query, every DML, the governor numbers and whatever it printed. nimbus converts one into the same trace it records for a local run, so a run that happened in an org gets the replay debugger, the timeline, the trace viewer and the MCP tools without any of them knowing where it ran. Free.

bash
nimbus log list -o myorg                  # what the org kept, newest first
nimbus log fetch 07Lg500000Cwnav -o myorg # bring one down to .nimbus/logs

nimbus log show 07Lg500000Cwnav.log       # levels, counts, exceptions, governor, debug output
nimbus log convert 07Lg500000Cwnav.log    # write it into .nimbus/traces as a replayable trace
nimbus log replay 07Lg500000Cwnav.log     # convert, then open it

sf apex get log -i 07Lg500000Cwnav | nimbus log show -   # straight from the org

Record a log worth replaying

Stepping needs statements, and statements start at Apex Code FINER; the variables the Variables view is made of start at FINEST. Set the trace flag's debug level to:

text
APEX_CODE,FINEST;APEX_PROFILING,FINEST;CALLOUT,INFO;DB,INFO;SYSTEM,DEBUG;VALIDATION,INFO;VISUALFORCE,FINER;WORKFLOW,INFO

A log recorded below that still converts — the queries, the DML, the debug output and the governor numbers are all there and all real — it just cannot be stepped. Every command here says exactly that, and names the level to set, instead of failing with something generic.

List and fetch

nimbus log list shows the debug logs the org currently holds, newest first — id, when it started, how big it is, how long it took, and what ran, with the exception that ended it when there was one. nimbus log fetch brings one down to .nimbus/logs/<ApexLogId>.log, which is the name the other commands read the log id from, so the trace it converts into can be traced back to the org record with no extra argument.

bash
$ nimbus log list -o myorg

  2 debug logs in myorg

  ID                  STARTED                   SIZE      TIME  OPERATION
  07Lg500000CwnavEAB  2026-08-28 11:45:00   50.2 KB      2.4s  /services/data/v67.0/tooling/executeAnonymous
  07Lg500000CwnZ0EAJ  2026-08-28 07:01:02     900 B      40ms  AccountTest  — System.AssertException

  Next
    nimbus log fetch 07Lg500000CwnavEAB -o myorg

Salesforce keeps a debug log for about a day and evicts the oldest once the org's allocation is full, so an empty list is the normal state of an org nobody traced this morning. Both commands say that rather than failing.

Show

nimbus log show writes nothing. It reports the API version and the category levels the log was recorded at, whether it can be stepped, what it did (statements, methods, queries, DML, triggers), the governor usage under the same keys a local run reports, every System.debug it printed, and every exception it threw — with the one that ended the transaction marked.

bash
$ nimbus log show ZZTrimTest.triggerCreatesOverlong.log

  API version   62.0
  Test          ZZTrimTest.triggerCreatesOverlong
  Duration      801.352923ms
  Steppable     yes

  Statements 1128 · Methods 176 · Queries 1 · DML 1 · Triggers 3 · Spans 1310

  Governor
    CpuTime              52 of 10000
    DmlRows              1 of 10000
    DmlStatements        1 of 150
    Queries              1 of 100

  Exceptions (7)
    [7] System.DmlException: Insert failed. First exception on row 0; first error: ...
  ! [0] System.AssertException: Assertion Failed: PROBE: Expected: @@, Actual: THREW=...

Convert

nimbus log convert writes the trace into .nimbus/traces, the same place a local run's trace goes and the same layout every trace consumer resolves runs by — so nimbus trace, nimbus trace timeline, the IDE trace panel and the MCP get_execution_trace tool all find it with no extra argument. The command prints the run id and the line that reopens it.

Several logs converted with the same --run accumulate into one run directory, exactly as the tests of one local run share one trace file. That is what makes a whole org test run one run rather than one per method.

Replay

nimbus log replay converts and opens the trace viewer. The session is loaded before anything is opened — the same call the replay debugger makes — so a log with nothing to step is refused by name, with the level to set, rather than opening an empty viewer. Outside an interactive terminal the trace is still written and the command prints the line that opens it, so reaching this from a script is safe.

In the IDE

The daemon exposes the same engine as nimbus/log.convert, taking either a file path or an ApexLog body already in hand, and answering with the trace path, whether it is steppable, and — when it is not — the reason and the debug level to set. That is what lets an editor open a debug log in the ordinary debug session: same stepping, same step-back, same Variables view, same timeline.

nimbus/log.list and nimbus/log.fetch are the same pair as the two commands above, which is what puts "Open Log from Org…" in the IDE: list, pick a row, fetch the body to a file, hand the path to log.convert.

Flags

log list:

FlagDefaultDescription
--limit100Show at most this many logs, newest first
--minefalseOnly logs recorded for the authenticated user (default: every log the session can see)
--jsonfalseEmit the listing as JSON

log fetch:

FlagDefaultDescription
--outDirectory to write the log into (default: the project's .nimbus/logs)
--jsonfalseEmit the path, id and size as JSON

log show, log convert and log replay take:

FlagDefaultDescription
--classLabel the trace with this test class (default: the log's first Apex code unit)
--methodLabel the trace with this test method (default: the log's first Apex code unit)
--log-idRecord the ApexLog id the body came from (default: the file's name)

The global -o / --org records which org the log came from. Nothing here contacts it — the log is already in hand — it only labels the trace with where the run happened.

log show and log convert:

FlagDefaultDescription
--jsonfalseEmit the conversion report — and, for convert, the trace path and run id — as JSON

log convert and log replay:

FlagDefaultDescription
--outDirectory to write the trace run into (default: the project's .nimbus/traces)

log replay:

FlagDefaultDescription
--runWrite into this run id instead of one derived from the log

A path of - reads the log from standard input, so a body piped straight out of the Salesforce CLI never has to touch the disk.

nimbus checkpoint

Capture the state of a line in an org, including everything the debug log does not record. A log holds a value because something assigned to it; a heap dump holds everything that was live at one line, assigned to or not — the field populated before the method started, the collection mutated through a reference, the record the log only mentions by address. Salesforce calls the mechanism a checkpoint, and it is the second input its own replay debugger reads. Free.

bash
nimbus checkpoint set AccountService:42 -o myorg
nimbus exec -c "new AccountService().recalculate();" -o myorg
nimbus checkpoint collect --run <runId> -o myorg
nimbus checkpoint clear -o myorg

What sets a checkpoint off

Code the org runs for you, on request: anonymous Apex, a Visualforce page, a button. A test run started through the API does not fire one — verified against a real org, with the checkpoint set, the line executed and no heap dump produced. That is the platform's behaviour and nimbus cannot route around it, so reach the line the way the platform will actually stop at it: call the code under test from nimbus exec.

This is the one thing checkpoints do not compose with. Everything else about an org run — the trace, the Variables view, stepping, the write history — comes from the debug log and works the same whether the code ran as a test or not.

Set

The line must be an executable line of the copy deployed to the org — a statement, not a comment, a blank line or a declaration. A local file that has drifted from the org is the usual reason a line is refused, and the refusal says so rather than repeating the platform's four words. Triggers work the same way: nimbus resolves whether a name is a class or a trigger against the org, so nothing has to be spelled specially.

bash
$ nimbus checkpoint set AccountService:42 AccountTrigger:17 -o myorg

  Set 2 checkpoints in myorg

  AccountService:42  (class)
  AccountTrigger:17  (trigger)

  They expire in about 30 minutes. Run the code, then
    nimbus checkpoint collect --run <runId> -o myorg

Class:line is read the way an editor puts it on the clipboard, so a path and an extension are both fine: force-app/main/default/classes/AccountService.cls:42. Setting one twice at the same line replaces it rather than consuming a second of the five.

The org's two limits

Five checkpoints per user, and about thirty minutes of life each. Both are the platform's, both are surfaced rather than worked around. The sixth is refused before anything is sent, naming the five that are in the way and which of them have already lapsed; nimbus checkpoint list shows how long each has left.

bash
$ nimbus checkpoint list -o myorg

  2 of 5 checkpoints set in myorg

  WHERE                           EXPIRES    HEAP DUMP
  AccountService:42               12m        yes
  AccountTrigger:17               expired    yes

Collect

A checkpoint announces itself nowhere except in the debug log of the transaction that hit it, so that log is the input. nimbus checkpoint collect reads the HEAP_DUMP lines out of it, fetches each dump from the org, and writes heapdumps.json into the run directory next to trace.jsonl — where the replay debugger reads it, so the dump's variables appear in the Variables view beside the ones the log recorded.

bash
nimbus checkpoint collect run.log --run <runId> -o myorg
nimbus checkpoint collect --log-id 07Lg500000CwnavEAB --run <runId> -o myorg

Without --run or --out the most recent run under .nimbus/traces is used, and the command says which one it chose. Heap dumps expire independently, so one that has lapsed is reported and the rest are still written — a run that captured four of five is worth having.

Dumps are fetched one at a time. That is not a missed optimisation: a Tooling query selecting a heap dump returns exactly one record, and asking for several fails the whole query rather than returning fewer.

Flags

checkpoint set:

FlagDefaultDescription
--iteration1Which pass through the line to dump (1 is the first)
--jsonfalseEmit the created checkpoints as JSON

checkpoint list:

FlagDefaultDescription
--jsonfalseEmit the checkpoints as JSON

checkpoint collect:

FlagDefaultDescription
--runWrite the sidecar into this run id under .nimbus/traces (default: the most recent run)
--outWrite the sidecar into this directory instead of a run under .nimbus/traces
--log-idFetch this ApexLog from the org instead of reading a local file
--jsonfalseEmit the sidecar path and counts as JSON

nimbus checkpoint clear takes no flags: with no argument every checkpoint set for you goes, and with a Class:line or an id, just that one. Every subcommand reads the global -o / --org.

Setting a checkpoint needs the View All Data permission — the same one the platform requires of its own client. It does not need an Apex Debugger licence.

nimbus flow

Inspect the Flows in a project without opening an org. nimbus already parses every .flow-meta.xml to fire record-triggered flows during tests; this command family exposes that same parse as something you can read. Free.

bash
nimbus flow list                       # every flow: name, type, trigger, status
nimbus flow graph Order_Automation     # one flow's elements and connectors
nimbus flow graph Order_Automation --json
nimbus flow graph Order_Automation --mermaid
nimbus flow graph Order_Automation --out order.html   # self-contained interactive viewer
nimbus flow graph force-app/main/default/flows/Order_Automation.flow-meta.xml

List

nimbus flow list tables every flow in the package directories — name, label, type, trigger object, status. Draft and Obsolete flows are listed like Active ones: they are still source, and the flow that no longer runs but still names an Apex class is exactly the one worth finding before that class is deleted.

Graph one flow

nimbus flow graph <name> prints the flow's identity — process type, trigger, entry-condition count, status, API version, resource counts — then every element with its source line and every connector. The argument can also be a .flow-meta.xml path, which works outside a Salesforce project too.

bash
$ nimbus flow graph Order_Path

Order_Path — flow
    "Order Path"
    AutoLaunchedFlow · Active · API 59.0
    RecordAfterSave on Order__c (CreateAndUpdate)

  Elements (5)
           start                Start  — RecordAfterSave on Order__c (CreateAndUpdate)
       20  decision             Check Amount  — 1 rule
       37  recordUpdate         Save Order  — update Order__c
       ...

  Connectors (6)
    Start → Check_Amount
    Start → Notify  [scheduled: Reminder (+3 Days)]
    Check_Amount → Save_Order  [rule: Large order]
    Save_Order → Notify  [fault]

Every edge is typed rather than merely drawn: decision rules carry their rule label, the loop's next-value and no-more-values connectors are split, scheduled paths carry their offset, and fault connectors are marked — so a renderer can dash the error path without re-deriving flow semantics. Element line numbers come from the same index the flow debugger keys breakpoints by.

Exports

--json emits the nimbus.flowgraph/v1 document; --mermaid and --dot export diagrams with fault edges dashed and rule edges labeled. With --out, a .html path writes a self-contained interactive viewer — pan, zoom, hover to light a step's neighbourhood, click to pin its facts — with no server and no external assets, and a .md path wraps the Mermaid diagram so VS Code, GitHub and Obsidian render it.

In the IDE

IntelliJ has the same viewer as View Flow: in the editor and Project-view right-click menus of any .flow-meta.xml, and on the flow's editor capsule, which also shows the flow's identity facts (label, type, trigger object, status, API version). Double-clicking a node opens the flow file at that element's exact line. The renderer is the same file every other host uses, so all surfaces draw one definition.

Flags (flow graph)

FlagDefaultDescription
--jsonfalseEmit the flow graph as JSON (schema: nimbus.flowgraph/v1)
--mermaidfalseExport the flow as a Mermaid flowchart
--dotfalseExport the flow for Graphviz
--outWrite to a file instead of stdout; a .html path gets the self-contained interactive viewer, a .md path a rendered Mermaid document

nimbus bisect

Find the commit that broke a test, using git bisect — made practical for Apex because each step is a local, sub-second test run instead of an org deploy. Free.

bash
# AccountTest.testDiscount used to pass at v1.40.0 and fails now
nimbus bisect "AccountTest.testDiscount" --good v1.40.0

# Bad ref other than HEAD
nimbus bisect "AccountTest.testDiscount" --bad abc1234 --good v1.40.0

# Machine-readable result for CI
nimbus bisect "AccountTest.testDiscount" --good v1.40.0 --json

How it works

The search runs inside a temporary git worktree of the project's own repository — never your actual checkout. git bisect moves HEAD as it walks history, which would be destructive against a checkout other sessions or tools might be using, so nimbus bisect creates a disposable worktree, drives the whole bisection there, and removes it before exiting — including on Ctrl-C. Each worktree gets its own embedded database automatically (the port and database name are derived from the worktree's path), so a bisect run never collides with the database from your regular checkout.

At each candidate commit, a probe step runs nimbus test "<pattern>" --no-daemon in the worktree and reports one of three verdicts back to git bisect run: good (the test passed), bad (the test actually failed), or skip when the class or method doesn't exist yet at that commit. Skipping a missing test rather than calling it "bad" matters — otherwise bisect would blame whichever commit happens to introduce the test for a failure that is really somewhere later in history. A build, parse, or config error that never got to evaluate the test is also treated as a skip rather than a guess.

Output

Progress streams as the bisection narrows the range. On convergence, a verdict block reports the culprit commit (hash, author, date, subject), how many steps it took and how long, and a copy-pasteable command that reproduces the failure in its own disposable worktree. --json emits a nimbus.bisect/v1 payload on stdout instead — progress moves to stderr so a CI run watching stdout still gets clean JSON, while someone watching the terminal still sees steps happen in real time.

Flags

FlagDefaultDescription
--goodRef known to have the test passing (required)
--badHEADRef known to have the test failing
--jsonfalseEmit the versioned JSON result (schema: nimbus.bisect/v1)

nimbus bisect runs against the project's own git history — the Salesforce project under test, found the same way every other nimbus command finds it. If that directory isn't a git repository, or --bad/--good don't resolve to commits, nimbus bisect fails with a clear error before creating a worktree.

nimbus bench Pro

Run a test method N times and report timing statistics: mean, median, p95, p99, min, max. Useful for catching slow SOQL, comparing performance before and after refactors, and setting performance budgets in CI.

While the benchmark runs, a live progress view updates with rolling stats after each sample. Press q to stop early and show results from completed runs.

bash
# Benchmark a test method (100 runs, warmup enabled by default)
nimbus bench AccountServiceTest.testInsertAccount

# 50 runs, no warmup
nimbus bench AccountServiceTest.testInsertAccount --runs 50 --warmup=false

# JSON output - pipe to jq for CI integration
nimbus bench AccountServiceTest.testQuery --format json

# CSV output - one row per sample
nimbus bench AccountServiceTest.testQuery --format csv > results.csv

CI integration

JSON output pipes cleanly to jq. Fail your pipeline if p95 exceeds a threshold:

bash
nimbus bench AccountServiceTest.testQuery --runs 50 --format json \
  | jq 'if .p95_ms > 100 then error("p95 exceeded 100ms") else . end'

Flags

FlagDefaultDescription
-n, --runs100Number of times to run the test method
--warmuptrueDiscard the first 10% of runs to exclude cold-start overhead
-f, --formattableOutput format: table, json, csv

Bench history

After each run, results are appended to .nimbus/bench-history.jsonl - one JSON line per benchmark with timestamp, method, and all timing stats. Use this file to track performance trends over time in external tooling.

Traces & Analytics

A test run with --trace records every span of its execution — methods, SOQL, DML, triggers and every statement — into .nimbus/traces/<run>/trace.jsonl. The trace is what powers replay debugging, the trace viewer, and the field ledger below. Runs started from the IDE record the same thing, and both leave the same artifacts behind.

Execution Traces

A trace is the record of what a test actually did: which methods it entered, which SOQL it issued, which DML it ran, and in what order. Nimbus does not collect one unless you ask, because collecting it costs time on every statement.

bash
# Run a test with tracing on
nimbus test AccountServiceTest.testCreate --trace

# Open the trace in the viewer
nimbus trace AccountServiceTest.testCreate

# Most recent trace, whatever test produced it
nimbus trace --last

nimbus test --trace writes traces to .nimbus/traces/. nimbus trace opens the interactive TUI viewer on the newest trace file there and roots the flow at the test you name; with no argument, or with --last, it roots at whatever ran.

If you collected somewhere other than .nimbus/traces/, point the viewer at the same place with --trace-output, or set nimbus.trace.output in nimbus.properties so both sides read it and neither needs the flag.

Verbosity

Two flags share one ladder. --trace-level on the test run decides how much gets recorded; --level on the viewer decides how much of a recording is shown, hiding spans below the level you ask for.

Filtering only ever subtracts. Recording at minimal and then viewing at debug shows you nothing extra, because the detail was never written down — so the level that matters most is the one you collect at.

FlagDefaultDescription
minimalMethod entry and exit only
normalMethods, SOQL, DML — the cheapest level that answers most questions
verbosedefaultAdds statement-level detail. Replay steps these spans, so this is what --trace records unless you ask for less
debugAdds variable values as they change
systemAlso traces Nimbus-internal frames

Flags

nimbus test:

FlagDefaultDescription
--tracefalseCollect a replayable execution trace for each test that runs
--trace-levelverboseHow much to record: minimal, normal, verbose, debug, system. Anything else is refused rather than quietly treated as normal. Below verbose the trace can be read but not replayed.
--trace-output.nimbus/tracesDirectory for trace output files. Pass the same directory to nimbus trace, or set nimbus.trace.output to change both.

nimbus trace:

FlagDefaultDescription
--lastfalseRoot the flow at whatever ran, ignoring a test name argument
--levelnormalHide spans below this verbosity: minimal, normal, verbose, debug, system. Cannot show detail the recording did not capture.
--trace-output.nimbus/tracesDirectory to read traces from. Also settable as nimbus.trace.output; a relative path is resolved against the project root.
--run-Open one recorded run instead of the most recent. Accepts a run directory, its full name, or any unambiguous suffix. This is what the replay line under a failed test uses, so it keeps working after later runs record traces of their own.

The viewer needs an interactive terminal. Piped or run under CI it prints the directory the traces are in and exits successfully, so a CI step that reaches it by accident does not fail the build. The same traces are rendered inline by the VS Code trace viewer.

nimbus trace Pro

nimbus trace opens the interactive trace viewer for a test's most recent recorded run:

bash
nimbus test "AccountServiceTest.*" --trace   # record
nimbus trace AccountServiceTest.testCreate   # inspect
nimbus trace --last                          # newest trace, any test

nimbus trace timeline — the run on one axis

Where nimbus trace shows one transaction's call tree, nimbus trace timeline puts a whole recorded run on a single time axis: test methods, Apex calls, SOQL, DML, triggers, flows and the rest of the save pipeline as lanes of bars, with every field write as a tick and every span's governor delta as a reading. Statement-level spans are left off by default and the header reports how many; --detail places them.

bash
nimbus trace timeline                            # newest traced run
nimbus trace timeline 20260824T101530_a1b2c3d4
nimbus trace timeline --json                     # the nimbus.timeline/v1 document
nimbus trace timeline --out run.html             # self-contained interactive viewer
nimbus trace timeline --detail                   # with the statement-level spans
nimbus trace timeline --diff 20260823T090000_9f8e7d6c
FlagDefaultDescription
--jsonfalseEmit the nimbus.timeline/v1 document instead of the textual timeline
--out-Write to a file; a .html path gets the self-contained interactive viewer, anything else the JSON document
--width60Columns the textual timeline strip is drawn across
--detailfalseInclude the statement-level spans (assignments, branches, loop iterations), drawn inside the bar that ran them
--diff-Compare against another traced run, aligned by test method name; prints a per-method duration delta table, slower first

--detail is off by default because a verbose recording holds orders of magnitude more statement spans than lanes of bars. They are not a lane of their own: each is drawn inside the bar that ran it, once the axis is magnified far enough for a statement to be worth a pixel. The document reports detail_available either way, so you can tell whether asking would show anything.

--diff takes a second run id and aligns the two by test method name — never by a run identifier, since trace run ids and test-history ids are separate id spaces. The two runs are not laid on one axis: they do not share a clock, so what is compared is how long each method took, not where it fell. The viewer draws one row per method with the current run's bar over the baseline's; the terminal prints the same ordering as a delta table.

The lanes, the span bars and the governor readings are free — they are span data, and spans are the trace. The field-write ticks are a ledger surface and need Pro Pro; a timeline built without it says so rather than showing an empty track.

The HTML export and the IDE panel share one renderer, so the page you write and the panel JetBrains opens are the same viewer. In the IDE (Nimbus → View Run Timeline, or the Run Timeline button on the debugger toolbar during a replay session) clicking a bar seeks the replay to that instant — landing on the last recorded step at or before the click. With no replay session open the position is copied to the clipboard and the panel says so.

nimbus trace ledger — the field ledger Pro

Every traced run also records the causal field ledger, written as ledger.db beside the trace whether the run started from the CLI or from the IDE. It holds each SObject field write, with the old value, the new value, and the frame that performed it — your Apex line, a trigger line, or the engine itself (a formula resolve, a roll-up recompute, a platform default). nimbus trace ledger queries a run's ledger; it answers "why is this field this value?" without re-running anything.

bash
nimbus trace ledger                              # newest traced run, every write
nimbus trace ledger --field Account.Name         # one field's history
nimbus trace ledger --record 001Kj00000AbCdEfGH1 # one record's history
nimbus trace ledger 20260824T101530_a1b2c3d4 --json

Each row reports the test, sequence number, timestamp, object, record, field, old -> new, the writer kind (apex, trigger, flow, default, rollup, formula, platform), and the writing location — Class.method:line for code, or the pipeline phase / roll-up definition for engine writes, which have no source line to point at.

FlagDefaultDescription
--field-Filter to one field, as Object.Field (e.g. Account.Name)
--record-Filter to one record: a platform Id (15 or 18 chars) or a ledger identity (rec-N)
--jsonfalseEmit rows as JSON instead of the table

The run-id argument is a directory name under .nimbus/traces (any unambiguous suffix works); omitted, the newest traced run is used. The raw apex.field.write events are in every trace at any tier — the indexed, queryable ledger is Pro.

The same ledger drives Find Where This Was Set in the IDE: during a replay debug session, right-click a field in the Variables view and Nimbus runs the recording backwards to the write that set it, via standard DAP data breakpoints.

Test Analytics

Every nimbus test run is recorded under .nimbus/history/ whether or not you asked for it. nimbus analytics reads that history back as trends: pass rate and duration over time, rather than the state of the last run.

bash
# Trends over the last 30 days
nimbus analytics

# Narrow the window
nimbus analytics --days 7

Flags

FlagDefaultDescription
--days30Number of days to include in the trend data

The dashboard is a TUI. Piped or under CI it emits JSON instead — the requested days alongside the raw runs the trends are computed from, so a build step can chart or threshold them itself.

bash
# Pass rate of the most recent run
nimbus analytics | jq '.runs[0] | .passedTests / .totalTests'

The JSON dump reads up to 1000 runs regardless of --days; the flag scopes the interactive view. Trends need history to exist — a fresh project shows nothing until it has runs behind it, and nimbus reset deletes them along with the rest of .nimbus/.

Flaky Detection

A flaky test is one that changes its answer while nothing that could legitimately change it moved. nimbus history --flaky reports them out of the same run history the analytics view uses.

bash
nimbus history --flaky

What counts as a flip

Not every pass-then-fail is flakiness, and treating it as such is how a flaky-test report becomes noise nobody reads. Nimbus records the conditions of every run alongside its results, and compares only runs whose conditions match:

FlagDefaultDescription
sourceFingerprintA digest of the project’s Apex sources. Different fingerprint means different code — fixing a bug must not mark the test flaky.
fullRunFalse when the run was narrowed by a pattern or --impacted. A test absent from a narrowed run did not pass or fail; it did not run.
workersThe parallel worker count. Contention is the most common real source of order-dependent failure, so runs at different widths are not compared.
isolationThe test isolation mode, for the same reason.
tierThe entitlement the worker count was resolved under — without it, a deliberate -p 1, a single-core machine and a lapsed activation are indistinguishable afterwards.

An outcome flip is evidence of non-determinism only when all of that held still. Records written before this environment data existed carry none of it and are not comparable.

Reading the report

The report is part of the history TUI and needs a terminal: --flaky selects the flaky view over the run list. A piped nimbus history always emits the run list as JSON — the flag does not change that, so scripting a flakiness gate means consuming .results[] across runs yourself.

Flakiness is a property of a history, not of a run, so the report is only as good as the history behind it. A test that has run twice cannot be called flaky; one that flips under -p 8 and never under -p 1 is telling you about contention, which is a real bug and not a measurement artifact.