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 renmy runtime, and they work differently:

  • In tests - Salesforce does not allow real callouts inside @isTest methods. Use Test.setMock() with a standard HttpCalloutMock implementation. renmy enforces the same rule as a real org.
  • In renmy exec - Apex runs outside of a test context, so Test.setMock() is not available. Use --mocks to provide a config file that renmy 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. renmy enforces the same rule.

The solution is the standard Salesforce pattern: implement HttpCalloutMock and register it with Test.setMock(). renmy 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 renmy runtime. No renmy-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 renmy exec, Test.setMock() is not available. Any Http.send() call needs to be intercepted at the renmy level.

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

Using a mock file

bash
renmy exec -c "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
renmy exec -c "MyClass.run();" --mock "GET:https://api.example.com/v1/users:200:[]"

# multiple --mock flags
renmy exec -c "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

An endpoint with no matching mock entry throws System.CalloutException: Endpoint not mocked: <METHOD> <URL>, naming the request so you can add the entry. Nothing reaches the network: renmy exec makes no real callout, with or without a mock file.

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
renmy exec -c "MyClass.run();" --mocks mocks/auth.yaml --mocks mocks/orders.yaml

URL Patterns

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

renmy test

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

Pattern matching

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

Path patterns

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

Running renmy 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. renmy 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
renmy test "*"                 # no classes found -> exit 1
renmy test "*" --allow-empty   # no classes found -> warning, exit 0

A run that left out a class fails as well. A file that does not parse and a class the compile gate excludes (code the platform would refuse to compile) are not in the run: their tests did not run, and calls into them returned null. The platform could not have run that suite, so renmy exits 1 even when every test that ran passed, and names the files. --allow-compile-errors, or nimbus.test.allow-compile-errors=true in nimbus.properties, lets the tests that ran decide the exit code.

With --json, status agrees with the exit code. It is "failed" when a test failed, when a class was left out, and when nothing matched, and reason says why when the test counts do not ("no tests matched NoSuchTest", "1 file failed to parse and was excluded from the run: Broken.cls"). parse_errors lists each file that did not parse with its parser messages, and compile_excluded lists each class the compile gate excluded as { class, file, error } with its first error. Both are empty lists on a run that left nothing out, and reason is absent.

bash
renmy test --json                          # Broken.cls does not parse -> "status": "failed", exit 1
renmy test --json --allow-compile-errors   # same run -> status and exit code from the tests

Package scope

bash
renmy test -k apex-stream-streams             # Every test in one package
renmy test -k apex-stream-streams "Stream*"   # Its tests that match the pattern
renmy test -k core,billing                    # Two packages
renmy test -k apex-stream-streams --isolate   # Load only it and its dependencies

-k names an entry of packageDirectories in sfdx-project.json: its package name, a packageAliases key, its 0Ho id, or its path. renmy runs the test classes in that directory and in the package’s unpackagedMetadata directory. A package nested inside that directory is a different package and is left out. Every package’s code and schema are still loaded, so a test that passes in a full run passes here.

A positional pattern narrows the package’s tests further. Without one, the run takes every test in the package and does not auto-scope to the current directory. --coverage reports the package’s files only, with totals over those files. History records the run as partial, the impact map and the recorded suite duration are not replaced by it, and --json adds a packages key. With --in-org, renmy sends the org the names of the package’s test classes. -k and -i cannot be combined.

--isolate loads what a package version build of the selected package sees: the package, its local dependencies from sfdx-project.json, stubs/ and .nimbus/retrieved. Before any test runs, renmy checks the package’s files against that set. A name that only a package outside it declares, such as a class in a sibling package the package does not list in dependencies, stops the run with exit 1, reported as renmy validate -k X --isolate reports it and grouped by the package that declares the name. A stopped run still writes --output-file and --results-xml. The header’s Loaded: line lists what the run loads, and a Note: names a pinned dependency that loads from local source, a dependency that is not in the project, or a path-only entry, which has no dependencies. The run uses a database of its own in the project’s local Postgres cluster and keeps its schema cache under .nimbus/isolate. It writes no impact map, durations or failure traces, and history records it as isolated. --impacted runs every selected test. Schemas fetched from an org with renmy sync -o still load, because they describe the org, which holds every package.

bash
Defined in sibling:
  error feature/classes/FeatureService.cls:4  SEMA-TYPE-001 — Invalid type: SiblingHelper. FeatureService uses SiblingHelper (defined in package sibling), which feature does not declare in dependencies

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
renmy test -p 8     # 8 parallel workers (Pro)
renmy test -p 16    # Push it harder on fast machines
renmy test           # Sequential (Free tier default)

Coverage

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

Org fallback Pro

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

When renmy 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 full run (renmy test "*", or no pattern) replaces the file, and removes it when nothing retrievable is missing; a run with a pattern only adds to it, so a narrowed run never drops what a full run found. A comment at the top says which kind of run wrote it, and each member carries <!-- referenced by <file> -->, naming the class whose test met the reference — a name that no retrieve can satisfy, such as one a negative test expects to be missing, is recognisable by its referrer.

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. renmy 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 renmy parses but Salesforce would refuse to compile. renmy 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 renmy 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. renmy 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 renmy validate and the editor both report it as the compile error the org reports.

Platform members answered by a fallback. A platform method, static member or enum member that renmy has not implemented is answered by a placeholder in a few places (an empty ConnectApi result object, a null from a static get, a class name from getName(), or a type-like value for an unknown static or enum member). Such a test may pass locally and fail in the org. Every hit is counted, and the run ends with ⚠ N platform member(s) answered by a fallback, listing up to twelve entries as kind: Type.member ×count (first at File.cls:line). In renmy test --json the same list appears under platformFallbacks (objects with kind, type, member, count and firstSeen; the key is absent when nothing was hit). renmy exec prints the same block after its output. Set NIMBUS_FALLBACK_TRACE=1 to log every hit to stderr as it happens.

Names the org rejects at compile time. Some calls compile nowhere but in renmy runtime: a method the platform type does not have (Wave.Templates.list()), new on an abstract class, an interface or a type with no public constructor (new ConnectApi.BatchResult()), a constructor with an argument count none of the documented ones takes, and an exception type the platform does not have (new System.TotallyMadeUpException('m')). With nimbus.strictPlatformApi=false in nimbus.properties, renmy still runs them and lists them after the run in a second block, ⚠ N name(s) the org rejects at compile time, with the same line format and kinds invented-member, abstract-new, ctor-arity, invented-exception and carrier-property; renmy test --json carries them in platformFallbacks under those kinds. By default renmy refuses them instead: each throws a catchable System.TypeException with the org's compile message (Method does not exist or incorrect signature: void list() from the type Wave.Templates, Constructor not defined: [ConnectApi.BatchResult].<Constructor>(), Invalid type: System.TotallyMadeUpException), and the compile gate also drops a class over four rules renmy validate reports on its own: an abstract or override method with no access modifier (API 65 and later), listVar instanceof Iterable<String> (API 60 and later), an unknown exception type in catch, new or instanceof, and a List assigned to a variable that is not a collection. The setting is on by default; false is the way back to running them.

Standard objects with no local model. A reference to a standard Salesforce object renmy 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: renmy sync -s AccountContactRelation -o <alias>. In renmy 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
renmy test --trace                          # Record a replayable trace
renmy test --trace --trace-level normal     # Cheaper, not replayable
renmy 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)
renmy test --org-default "TriggerSettings__c.IsEnabled__c=true"
renmy 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
renmy test -e __tests__       # Exclude paths containing "__tests__"
renmy test -e legacy -e wip   # Multiple exclusions
FlagDefaultDescription
-p, --parallelNumCPU on Pro, 1 on FreeNumber of parallel test workers. More than one worker is Pro; the flag left unset resolves to one worker per core on Pro and to one worker on Free.
--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/). A run of the whole suite rebuilds the map. A run narrowed by a pattern, -k, --shard or --impacted merges its coverage into the map, and a change stays pending until the tests that cover it have 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)
--as-Run as this local user, named by Username. UserInfo, the OwnerId new records default to and custom permission checks answer as them; object, field and sharing checks still apply only inside System.runAs. Overrides nimbus.runtime.user for this run; a username no local user carries fails before anything executes.
--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. Also lists every profile and object of the runAs field reads answered without field grants, with the fields read.
--strict-permissionsfalseFail tests whose passing path consulted a permission check outside System.runAs() (CI gate)
--api-version-warningsfalseReport classes that reference a symbol newer than their own <apiVersion> (CT-API-001). Off by default: the platform does not gate symbol references on a class's apiVersion (a class pinned at 46.0 deploys calling System.Assert, org-verified), so the block is a list of "newer than your pin", not a deployability finding. Turning it on also parses every class in the CLI process. Set nimbus.test.api-version-warnings=true to make it the default for a project.
--feature-Enable org features: MultiCurrency, PersonAccounts, StateAndCountryPicklist (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 renmy 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
--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 renmy 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
-k, --package-Run only the tests in this sfdx-project.json package: its package name, a packageAliases key, its 0Ho id, or its path (repeatable, comma-separated). Intersects with a positional pattern. The whole project is still loaded unless --isolate is given; coverage, history and --json are scoped to the package. Cannot be combined with -i. See Package scope.
--isolatefalseWith -k, load only the selected packages, their local dependencies, stubs/ and .nimbus/retrieved, as a package version build does. Before any test runs, a name the package uses that only a package outside that set declares stops the run with exit 1. The run uses a database of its own in the project’s local Postgres cluster and keeps its schema cache under .nimbus/isolate, and history marks it isolated. Schemas fetched from an org with renmy sync -o still load, because they describe the org, which holds every package. With --local-shards each shard isolates on its own database. --json adds an isolate block: loaded, notLoaded and findings, as renmy validate --isolate writes it. Refused with --in-org, -i, --record, --fetch-missing, --write-stubs, --evidence, --db-url, DATABASE_URL and --db-provider external, which would share a database or state with every other run.
--allow-emptyfalseExit 0 when the project contains no tests at all, or, with -k, when the package has none, or, with --in-org, when the org runs none. 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.
--allow-compile-errorsfalseLet the tests that ran decide the exit code and the --json status when a project file did not parse or the compile gate excluded a class. Without it that run exits 1 and its status is "failed", even when every test that ran passed. Persist via nimbus.test.allow-compile-errors=true.
--no-daemonfalseDon't start or connect to a daemon; the run uses a standalone database. With Pro a run starts one by default; leave it out in a container that lives for one run.
-q, --quietfalseMinimal output — show only the summary and failures
--output-file-Write the test output to this file as well as stdout. If the file cannot be written in full (a full disk, say), the run reports it and exits 1; the same holds for --results-xml and --coverage-report.
--readonlyfalseAbort DML inside tests with a System.InvalidReadOnlyUserDmlException, as the org does in read-only mode, and make Organization.IsReadOnly true. See Read-only mode.
--write-stubsfalseAfter the tests pass, generate stubs/<Name>.cls for every unresolved class reference (Pro). See renmy stub.
--write-stubs-mergefalseWith --write-stubs, append new members to an existing stub instead of skipping it, preserving hand edits.
--write-stubs-forcefalseWith --write-stubs, overwrite an existing stub file, discarding hand edits.
--write-stubs-update-configfalseWith --write-stubs, register the managed-package namespaces it detected in nimbus.properties before generating.
--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 — renmy 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, renmy 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: renmy 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. A failure trace divides the bound between the tests it records: each may write max-bytes / (2 × tests) of its own spans and as much again for its @testSetup, so one heavy test cannot use up the recording of the others. The spans that name each test, and the calls still open where a test was cut, are written past the bound; that is a few hundred bytes per test. 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. A failure trace divides it between the retraced tests (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: renmy test --shard ${{ matrix.shard }}/4 --results-xml results-${{ matrix.shard }}.xml

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.

renmy 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
renmy test -p 8

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

# If you see many sequential re-runs, back off
renmy 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 renmy test processes, each with its own embedded Postgres instance, splitting the suite the same way --shard does. Pass a number, or auto to let renmy 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
renmy test --local-shards 4       # 4 concurrent shards, 4 separate databases
renmy test --local-shards auto    # Let renmy choose (by cores and suite length)

Each shard gets a persistent background daemon (visible in renmy 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.

renmy test:watch Pro

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

bash
renmy test:watch              # Watch all tests
renmy test:watch -i           # Select package scope interactively
renmy test:watch -k core      # Re-run only the tests in package "core"
renmy test:watch -k core --isolate   # Each run loads only core and its dependencies
renmy test:watch --pull -o dev       # Also pull what a test run reads when it changes in the org dev

While watching:

bash
r    # Re-run the whole pattern, as the first run does
w    # Show or hide the watched directories
p    # Pause or resume org polling (with --pull)
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. Pressing r always runs the full pattern.

--pull -o <alias> makes the watcher poll that org as well, every 15 seconds unless --pull-interval says otherwise, for Apex classes and triggers changed there. Each one is written into the project where it already lives (a new one into the default package, or the -k package), and the tests it affects run again. A source-tracked org (a scratch org, a sandbox with tracking) is read through the Tooling object SourceMember; any other org through the LastModifiedDate of ApexClass and ApexTrigger. A file edited locally is never overwritten: nimbus compares it with its last sync from that org, or asks git when there is none, keeps your copy and lists a conflict. Metadata no test run reads, such as profiles, layouts and reports, is listed. Polling stops when the org has fewer than 1,000 API calls left for the day, or after three failed polls in a row; p starts it again.

On a source-tracked org the declarative metadata a test run reads is pulled as well: objects, fields, validation rules, record types and field sets, flows, labels, custom metadata records, permission sets, value sets, workflow, assignment, duplicate and matching rules, approval processes, email templates, static resources and Visualforce pages. It comes through sf project retrieve start, which renmy answers itself when it maps every type asked for; without the Salesforce CLI only Apex is pulled. A file edited locally is kept: before the retrieve renmy asks git about every file the member lands in, and one git does not hold unmodified is left out and listed as a conflict. A pull that wrote one of these files runs the whole pattern, because no impact map links a label or a validation rule to the tests it affects.

FlagDefaultDescription
-i, --interactivefalseSelect package scope interactively before watching
-k, --package-Re-run only the tests in this sfdx-project.json package (name, alias or path; repeatable). Every package directory is still watched, because a change to a class in another package can change this package's tests. Cannot be combined with -i.
--isolatefalseWith -k, each run is renmy test -k X --isolate: it loads only the selected packages and their local dependencies, and a name only a package outside them declares stops the run and is listed in its place. Each run takes every selected test, because the impact map describes the whole project. Schemas fetched from an org with renmy sync -o still load. Cannot be combined with -i.
--no-impactfalseDisable automatic test impact selection; run the full pattern on every change
--pullfalseAlso poll the org named by -o, pull the Apex and the declarative metadata a test run reads when it changes there, and re-run the tests it affects. Needs -o.
--pull-interval15sHow often --pull polls the org. At least 5s.

renmy validate

Check Apex files for syntax errors and the semantic rules the platform enforces at deploy time, without running tests. Fast — no database or runtime needed. Errors come from the parser and the semantic checks; warnings from the metadata checks (CT-META-001/002: a missing *-meta.xml or absentapiVersion) and the SOQL column checks.

One list is opt-in: --api-version-warnings adds CT-API-001, which reads each file’s declared apiVersion and names every referenced symbol whose introduction version is newer. It is advisory, never a deploy finding: the platform does not gate symbol references on a class’s apiVersion — a class pinned at 46.0 deploys and runs calling System.Assert (56.0) andDatabase.Cursor (66.0), verified on a real org — so the list says “newer than your pin”, nothing more. The same switch exists onrenmy test (nimbus.test.api-version-warnings=true makes it a project default), where producing the block also parses every class in the CLI process.

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
renmy validate                  # Validate all Apex files
renmy validate MyClass          # Files matching "MyClass"
renmy validate *Service*        # Files matching "*Service*"
renmy validate -e __tests__     # Exclude paths containing "__tests__"
renmy validate --sema=warn      # Downgrade semantic findings to warnings
renmy 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 forrenmy validate path/to/MyClass.cls exactly as it is forrenmy validate MyClass. The project root is found from the file, not from your shell, so it works from anywhere.

bash
renmy validate force-app/main/default/classes/MyClass.cls   # Specific file
renmy validate force-app/main/default/classes/               # All files in directory
renmy validate src/tests/                                    # All files under path

Package scope

bash
renmy validate -k feature              # Only feature's files; names resolve project-wide
renmy validate -k feature --isolate    # Resolve against feature and its dependencies only

-k validates the files of one packageDirectories entry, and its unpackagedMetadata directory, and runs the profile and field checks on them. Names still resolve against every package directory and stubs/, so a file that validates in the whole project validates scoped.

--isolate resolves names against what a package version build of the selected package sees: the package, its local dependencies from sfdx-project.json, and stubs/. A class in a sibling package that the package does not declare in dependencies is then an error, and renmy names the package that declares it:

bash
error feature/classes/FeatureService.cls:4  SEMA-TYPE-001 — Invalid type: SiblingHelper. FeatureService uses SiblingHelper (defined in package sibling), which feature does not declare in dependencies
FlagDefaultDescription
-e, --exclude-Exclude paths matching a substring or glob — ** spans directories, e.g. **/dist/** (repeatable)
-i, --interactivefalsePick a package directory interactively. It narrows the files validated; names still resolve against the whole project. Cannot be combined with -k.
-k, --package-Validate only the files in this sfdx-project.json package: its package name, a packageAliases key, its 0Ho id, or its path (repeatable, comma-separated). Intersects with a positional pattern. Names still resolve against every package directory and stubs/. --json adds a packages key. Cannot be combined with -i or with a .cls or .trigger argument.
--isolatefalseWith -k, resolve names against only the selected packages, their local dependencies and stubs/, as a package version build does. A name only another package declares is reported with that package, or, when it lives in a dependency’s unpackagedMetadata directory, with that directory. --json adds an isolate block: loaded, notLoaded and findings (file, line, name, class, package, definingPackage, reason).
--semaerrorSemantic analysis level: error (default, deploy-blocker), warn, or off.
--api-version-warningsfalseAlso list classes referencing symbols newer than their own <apiVersion> (CT-API-001). Advisory only: the platform does not gate symbol references on apiVersion, so the list is never a deploy finding.
--jsonfalseOutput results as JSON, including semantic warnings (and CT-API-001 when requested) 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 renmy 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

renmy 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 renmy 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. renmy 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
renmy lint                            # every class and trigger
renmy lint AccountService             # files matching "AccountService"
renmy lint *Service*                  # files matching "*Service*"
renmy lint force-app/main/default/classes/   # everything under a path
renmy lint --rules                    # what each rule protects against
renmy 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:  renmy test "*" --evidence

With one, it reports what happened

bash
renmy test "*" --evidence
renmy 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).
-k, --package-Lint only the files in this sfdx-project.json package (name, alias or path; repeatable), and its unpackagedMetadata directory. Intersects with the pattern. The @future index is still built from every package directory, so an enqueue in a loop reached through another package’s @future method is reported. --json adds a packages key. Cannot be combined with a .cls or .trigger argument.
--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 renmy 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 renmy. 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.

renmy exec

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

bash
# From a file
renmy exec script.apex

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

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

# Ends in a statement, so it runs as written - nothing is wrapped
renmy 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
renmy exec --persist seed.apex

The futures, queueables and batches a snippet enqueues run after it completes, in the order Test.stopTest() drains them, as the org runs them after the block commits. Each is named on an async: line, with its own failure if it throws; the snippet still counts as successful. A scheduled job waits for its cron, and a snippet that throws runs none of them. --persist keeps what the jobs write too.

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
renmy exec --in-org -o acme-dev script.apex

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

# The same thing as a JSON document, for a script
renmy exec --in-org -o acme-dev --json -c "System.debug(Limits.getLimitQueries());"
FlagDefaultDescription
-c, --code-Inline Apex code to execute
--mocks-YAML mock config file for HTTP callout interception (repeatable). See Exec mocking.
--mock-Inline mock as METHOD:URL:STATUS[:BODY] (repeatable).
--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
--as-Run as this local user, named by Username. UserInfo, the OwnerId new records default to and custom permission checks answer as them; object, field and sharing checks still apply only inside System.runAs. Overrides nimbus.runtime.user for this run; a username no local user carries fails before anything executes.

renmy run-method

Call one method of one class and report what it returned. Where renmy 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
renmy run-method AccountService.createDefault

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

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

# Structured result, including per-line variable snapshots
renmy 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.
--as-Run as this local user, named by Username. UserInfo, the OwnerId new records default to and custom permission checks answer as them; object, field and sharing checks still apply only inside System.runAs. Overrides nimbus.runtime.user for this run; a username no local user carries fails before anything executes.

The two overrides exist for a method renmy 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 extension renders as inline annotations — its "Run method" code lens and this command are one implementation.

The method runs against the local database with the same configuration renmy 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 renmy exec: you can call a method that inserts records without leaving them behind.

renmy 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
renmy compare -c "System.debug(Decimal.valueOf('1.5').round());"

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

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

# Compare every test class of one sfdx-project.json package
renmy compare --tests "*" -k billing -o my-sandbox

# Machine-readable result
renmy 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: renmy 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 renmy 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 renmy 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 renmy 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 renmy 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)
-k, --package-With --tests, compare only the tests in this sfdx-project.json package (name, alias or path; repeatable). renmy sends both sides the names of the package’s @isTest classes that the --tests pattern keeps, because the org selects tests by name. Refused with -c and -q.
--jsonfalseEmit the full comparison result as JSON

renmy 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 (renmy ships their describes); custom objects need a sync. Prints to stdout by default.

bash
renmy fixture Account Contact          # print a TestDataFactory to stdout
renmy 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, renmy 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
$ renmy 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 renmy 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.
-k, --packagedefault packageWith --write, save the class into this sfdx-project.json package instead (name, alias or path). One package. Refused without --write.
--api-version62.0API version for the -meta.xml when --write.

renmy coverage diff

Compare two JSON coverage reports (from renmy 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:
renmy test "*" --coverage-report base.json
renmy test "*" --coverage-report pr.json
renmy coverage diff base.json pr.json --fail-on-drop
FlagDefaultDescription
--fail-on-dropfalseExit non-zero when overall coverage decreased (CI gate).

renmy 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
renmy test "*" --evidence
renmy coverage lines AccountService
renmy coverage lines force-app/main/default/classes/AccountService.cls --all
renmy 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).

renmy 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 renmy'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
renmy budget init            # measure the suite, write nimbus.budgets.json
renmy budget check           # re-measure, exit 1 if any test is over budget
renmy budget check --json    # machine-readable result for CI
renmy budget init -k billing # measure one package, merge it into the file

The budget file

nimbus.budgets.json at the project root. renmy 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 renmy 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

renmy 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.

On a project with several sfdx-project.json packages, renmy budget init -k <package> measures one package's tests and merges them into the existing file instead of replacing it, so it needs no --force. Each measured test's entry is replaced or added. Every other entry is kept: other packages' tests, hand-written patterns, and tests that failed this time. A defaults counter the file already budgets is raised to the package's worst and never lowered, and a counter the file does not budget stays unbudgeted. Running it once per package gives the file an unscoped init would write.

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 renmy 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
0—Every measured test is within budget.
1—At least one budget was exceeded. This is the CI gate.
2—Configuration problem: no budget file, an invalid one, or a run in which nothing could be measured.

renmy budget check flags

FlagDefaultDescription
--pattern*Tests to measure (same syntax as renmy 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.
-k, --package-Measure only the tests in this sfdx-project.json package (name, alias or path; repeatable), intersected with --pattern. The whole project is still loaded. --json adds a packages key.

renmy budget init flags

FlagDefaultDescription
--pattern*Tests to measure (same syntax as renmy 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. Not needed with -k, which merges.
-p, --parallelnimbus.test.parallelTest workers.
-k, --package-Measure only the tests in this sfdx-project.json package (name, alias or path; repeatable) and merge their entries into an existing budget file, keeping every other entry. See Adopting budgets.

renmy agent (experimental)

Test an Agentforce agent's action surface locally — the actions, never the planner. renmy 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; renmy 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.

renmy 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 renmy agent list's job and executing it is renmy agent test's.

bash
renmy agent list                      # every action, and how each one resolves
renmy agent list --coverage           # ...and what no local test has reached
renmy agent list -k support           # only the agents in one package

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

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

What renmy 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 renmy was wrong or the model rolled differently. renmy 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: renmy executes apex:// and flow:// only.
Prompt templatesAn LLM call by definition, and renmy calls no LLM.
Custom Lightning TypesA c__caseInput parameter's shape lives in a Lightning Type Bundle renmy 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
renmy agent test --fixtures                     # every **/*.agenttest.yaml
renmy agent test --fixtures ./agent-tests      # one directory
renmy agent test --fixtures --case "Submit_Case/high priority escalates"
renmy 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. renmy 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 renmy 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. renmy 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. renmy 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 renmy report consumes this unchanged. On GitHub, run them as one more step after setup-nimbus:

yaml
- uses: renmy-solutions/setup-nimbus@v1
# setup-nimbus installs the CLI as nimbus, the same binary as renmy
- name: Run agent fixtures
  run: nimbus agent test --fixtures --results-xml agent-results.xml
  env:
    NIMBUS_LICENSE_KEY: ${{ secrets.NIMBUS_LICENSE_KEY }}

Flags

FlagDefaultDescription
--fixtures—Run conversation fixtures. Bare, it searches the project for **/*.agenttest.yaml; give a file or directory to narrow it.
--case—Run only cases whose "Action/Case name" contains this text. This is what the reproduce line in a failure uses.
--action—Single-action mode: run just this action with --params.
--params—JSON 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-xml—Path to save JUnit XML for the fixture run (Pro).
--coveragefalseCollect code coverage across the fixture run.
--coverage-report—Path 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.
-k, --package-agent list and agent test: act only on the agents whose planner bundle or .agent file is in this sfdx-project.json package (name, alias or path; repeatable). Their topics and actions, and the Apex and flows those target, still resolve against the whole project. With --fixtures, run the fixtures that name one of those agents; with agent list --coverage, list only their actions. --json adds a packages key.

renmy mutate Pro

Run mutation testing against your Apex code. renmy 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. renmy 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), renmy mutates the class under test instead — derived by stripping conventional Test prefixes/suffixes — and runs that test class against each mutant. This makes renmy 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, renmy 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, renmy refuses and names them.

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

# Mutate a specific class
renmy mutate AccountService

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

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

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

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

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

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

# Mutate one sfdx-project.json package's classes, judged by its own tests
renmy mutate -k billing

-k scopes both sides to one package: only its non-test classes are mutated, and only its @isTest classes run against each mutant. It intersects with the positional pattern, --class and --test. A test class named in the pattern whose class under test lives in another package is refused rather than mutated there. The whole project is still loaded.

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 < n → i < 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
-p, --parallelNumCPU on Pro, 1 on FreeNumber of parallel mutation workers. More than one worker is Pro.
--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.
-k, --package-Mutate only the classes, and run only the tests, in this sfdx-project.json package (name, alias or path; repeatable). Intersects with the pattern, --class and --test. --json adds a packages key. -p stays --parallel.

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."

renmy fuzz Pro

Property-based testing for trigger paths. renmy 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 renmy exec.

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

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

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

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

# Machine-readable report
renmy 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 `renmy 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 renmy 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.

renmy 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.renmy test output stays terse - this is the explicit expansion, so nothing about a normal run changes.

bash
# Explain a specific failure
renmy explain AccountServiceTest.testInsertAccount

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

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

# What does this command keep, and where?
renmy 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. renmy 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.

renmy 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
renmy triage

# Triage one class
renmy triage "AccountTest.*"

# Triage one sfdx-project.json package's tests
renmy triage -k billing

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

# Show at most 3 tests per group
renmy 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: renmy 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 renmy 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. renmy 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)
--no-trace-on-failurefalseDo not record a replayable trace for failed tests. Persist via nimbus.test.trace-on-failure=false.
-k, --package-Triage only the tests in this sfdx-project.json package (name, alias or path; repeatable), intersected with the pattern, as renmy test -k runs them. The whole project is still loaded. --json adds a packages key. A package with no matching test exits 1.

Suite health in doctor

renmy 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 renmy doctor stays fast enough for a pre-flight check. With --json the report appears undersuite_health.

renmy history

Browse past test runs in an interactive TUI. Every renmy 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
renmy history

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

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

Piped output is JSON

When stdout is not a terminal — a pipe, a CI runner — renmy history skips the TUI and emits the run list as JSON, so renmy 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

renmy 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
renmy history get 1756060500123456789

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

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

The run ID is the id field from renmy 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 (renmy 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 renmy explain's "last passed" comparison and the trend views in traces & analytics.

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

Bare renmy 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: renmy 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
$ renmy 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
$ renmy 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
$ renmy 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

$ renmy 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

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

bash
$ renmy 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 renmy does not run, carries no edge and is counted separately — renmy agent list names which. A project with no agent metadata sees none of this and pays nothing for it.

Circular dependencies

renmy 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
$ renmy 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
renmy graph --format dot | dot -Tsvg -o graph.svg
renmy 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.
renmy 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 (Renmy: 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, static resources and flows — in the dot and mermaid exports
--jsonfalseEmit the report as JSON (schema: nimbus.graph/v1)

renmy 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. renmy 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
renmy log trace --on -o myorg            # record my runs for the next two hours
renmy log list -o myorg                  # what the org kept, newest first
renmy log fetch 07Lg500000Cwnav -o myorg # bring one down to .nimbus/logs

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

renmy log delete --all --mine -o myorg   # make room in the org's log allocation

sf apex get log -i 07Lg500000Cwnav | renmy 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

renmy 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. renmy 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
$ renmy 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
    renmy 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.

Switch logging on

Salesforce records a debug log only while a trace flag is active for the user who runs the code. renmy log trace shows which users an org is currently logging and at what level, and switches logging on or off for one — the Setup > Debug Logs screen, from the terminal. Without --on or--off it only reads, and changes nothing.

bash
$ renmy log trace -o myorg

  3 trace flags in myorg

  TRACED                    LOG TYPE        LEVEL               APEX      EXPIRES
  René Knierim              USER_DEBUG      NimbusReplay        FINEST    2026-09-21 01:51
  René Knierim              DEVELOPER_LOG   SFDC_DevConsole     FINEST    2026-06-13 16:35  (expired)
  OrgFarm EPIC              DEVELOPER_LOG   SFDC_DevConsole     FINEST    2022-04-07 15:29  (expired)

  2 flags are expired. An expired flag records nothing and still holds its slot.

The level defaults to NimbusReplay, created in the org if it is missing and corrected if somebody edited it below FINEST, so the logs it produces can be stepped. --level names a different DebugLevel the org already has; if that one records Apex Code below FINER the command says so at the moment you choose it rather than letting the debugger refuse the log an hour later.

FlagDefaultDescription
--onSwitch debug logging on for the user
--offSwitch debug logging off for the user
--user <who>A username, a name, or a User id. Defaults to the authenticated user. An ambiguous name is refused with the candidates rather than resolved to the first match
--level <name>Record at this DebugLevel. Default: NimbusReplay
--minutes <n>How long to log for. Default 120. A trace flag lasts at most 24 hours — the platform's ceiling, not renmy's — and a longer value is capped with a warning
--jsonEmit the result as JSON

The platform allows exactly one trace flag per user per log type, so switching logging on for somebody who already has it replaces theirs. The command names the level it displaced rather than taking it quietly. renmy writes theUSER_DEBUG slot, never the DEVELOPER_LOG one the Developer Console takes for whoever has it open.

Debug levels

A trace flag says who is logged and until when. A debug level says how much gets written down, and several flags can point at the same one.renmy log level lists the levels an org has with every category each one records at, and creates or changes one — the Setup > Debug Levels page, from the terminal.

bash
$ renmy log level -o myorg

  2 debug levels in myorg

  LEVEL                   APEX      PROFILE   CALLOUT   DB        SYSTEM    VALID     VF        WORKFLOW  WAVE      NBA       ACCESS    USED BY
  NimbusReplay            FINEST    INFO      INFO      INFO      DEBUG     INFO      FINER     INFO      INFO      INFO      INFO      1 trace flag
  SFDC_DevConsole         FINEST    INFO      INFO      INFO      DEBUG     INFO      INFO      INFO      -         -         -         2 trace flags

  A log can be stepped only when Apex Code is FINER or FINEST.

Eleven categories, which is what the org reports rather than the eight most writing about debug logs lists: Wave, Nba andDataAccess are on every current org. Every category acceptsNONE, ERROR, WARN, INFO,DEBUG, FINE, FINER and FINEST; the per-category tables in Salesforce's documentation describe what each level writes down, not what the API will take.

bash
renmy log level set NimbusReplay --set ApexCode=FINEST -o myorg
renmy log level set Quiet --set ApexCode=ERROR --set Database=NONE -o myorg
renmy log trace --on --level Quiet -o myorg
FlagDefaultDescription
set <name>Create a level, or change one the org already has. A name the org has is an edit and only the categories named change; a name it does not have is a create, and the org defaults every category left out
--set <Category>=<Level>Repeatable. The category can be spelled as the API names it (ApexCode) or as Setup labels it (Apex Code). A category or a level renmy does not recognise is refused, because a setting dropped on the way out would be reported as saved
--label <text>The level's display label on a create. Default: its name
delete <name>Delete a level
--forceDelete a level that trace flags point at. Those flags are deleted too
--jsonEmit the result as JSON

Deleting a debug level deletes the trace flags pointing at it. That is the platform's behaviour, not renmy's: the API returns success, the flags are gone from the next listing, and the users they were logging stop being logged with nothing anywhere to say so. delete therefore refuses a level that is in use until --force, and the refusal names who would stop being logged.

Delete

Salesforce keeps a finite allocation of debug logs per org and evicts theoldest once it is full — so deleting is how you make room for the log of the run you are about to make, not housekeeping.

bash
renmy log delete 07Lg500000Cwnav -o myorg   # one, by id
renmy log delete --all -o myorg             # every log the session can see
renmy log delete --all --mine -o myorg      # only the logs recorded for you
FlagDefaultDescription
--allDelete every debug log the org is holding, not just the ids given. The org is listed again first, so it means all of what the org holds now — not what a listing was showing earlier
--mineWith --all, only the logs recorded for the authenticated user
--user <who>With --all, only this user's logs
--jsonEmit the result as JSON

Each id is reported separately, because deleting a hundred logs is a hundred outcomes. A log another session already evicted comes back as a refusal with the org's own reason — renmy cannot tell "already gone" from "not allowed" without guessing. A partial deletion exits non-zero, so a script that cleared an org and lost half the deletions does not carry on regardless.

Show

renmy 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
$ renmy 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

renmy 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 renmy trace, renmy 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

renmy 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.

renmy 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
renmy checkpoint set AccountService:42 -o myorg
renmy exec -c "new AccountService().recalculate();" -o myorg
renmy checkpoint collect --run <runId> -o myorg
renmy 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 renmy cannot route around it, so reach the line the way the platform will actually stop at it: call the code under test from renmy 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: renmy resolves whether a name is a class or a trigger against the org, so nothing has to be spelled specially.

bash
$ renmy 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
    renmy 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; renmy checkpoint list shows how long each has left.

bash
$ renmy 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. renmy 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
renmy checkpoint collect run.log --run <runId> -o myorg
renmy 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

renmy 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.

renmy flow

Inspect the Flows in a project without opening an org. renmy 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
renmy flow list                       # every flow: name, type, trigger, status
renmy flow graph Order_Automation     # one flow's elements and connectors
renmy flow graph Order_Automation --json
renmy flow graph Order_Automation --mermaid
renmy flow graph Order_Automation --out order.html   # self-contained interactive viewer
renmy flow graph force-app/main/default/flows/Order_Automation.flow-meta.xml

List

renmy flow list tables every flow a run reads: the package directories' flows, and each flow under stubs/flows/ that no package directory defines. For each it shows name, label, type, trigger object and status. The dependency graph draws the same flows. 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.

The output ends with one line saying whether the flows without a triggerOrder run in the org's activation order (from .nimbus/flows.json, with the sync date) or by API name.

Graph one flow

renmy 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
$ renmy 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.

Flags (flow list)

FlagDefaultDescription
-k, --package-List only the flows in this sfdx-project.json package (name, alias or path; repeatable) and its unpackagedMetadata directory. A package nested inside it is left out.

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

renmy 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
renmy bisect "AccountTest.testDiscount" --good v1.40.0

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

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

# Every step runs only the tests of one sfdx-project.json package
renmy bisect "Stream*" --good v1.40.0 -k apex-stream-streams

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 renmy 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 renmy 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.

With -k, every step runs renmy test "<pattern>" --package <ref> --no-daemon. The package is resolved against your checkout before the worktree is created, so a mistyped name fails at once, and again at each commit. A commit where the package is not declared yet, or its directory does not exist, is a skip, the same as a test that does not exist yet.

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)
-k, --package-Run each step's tests only in this sfdx-project.json package (name, alias or path; repeatable). A commit where the package cannot be resolved is skipped. --json adds a packages key, and the verify command repeats --package.

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

renmy 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)
renmy bench AccountServiceTest.testInsertAccount

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

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

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

CI integration

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

bash
renmy 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. renmy does not collect one unless you ask, because collecting it costs time on every statement.

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

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

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

renmy test --trace writes traces to .nimbus/traces/. renmy 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 renmy-internal frames

Flags

renmy 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 renmy trace, or set nimbus.trace.output to change both.

renmy 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.

renmy trace

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

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

Every email the run sent, from Messaging.sendEmail or a workflow, approval or flow email alert, is a step in the viewer with its recipients and template, and get_execution_trace returns them in the document's emails section. A message's target object is one of its recipients unless the message calls setTreatTargetObjectAsRecipient(false); the target then only fills the template's merge fields. Nothing is delivered. Emails are recorded at trace level normal and above.

renmy trace timeline — the run on one axis

Where renmy trace shows one transaction's call tree, renmy 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
renmy trace timeline                            # newest traced run
renmy trace timeline 20260824T101530_a1b2c3d4
renmy trace timeline --json                     # the nimbus.timeline/v1 document
renmy trace timeline --out run.html             # self-contained interactive viewer
renmy trace timeline --detail                   # with the statement-level spans
renmy 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.

renmy 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). renmy trace ledger queries a run's ledger; it answers "why is this field this value?" without re-running anything.

bash
renmy trace ledger                              # newest traced run, every write
renmy trace ledger --field Account.Name         # one field's history
renmy trace ledger --record 001Kj00000AbCdEfGH1 # one record's history
renmy 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)
--test-Filter to one test: Class.method (its own writes and its class’s @testSetup), or Class for every test of the class. On a recording from before ledger identities became run-wide, this keeps --record rec-N on one test
--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 renmy runs the recording backwards to the write that set it, via standard DAP data breakpoints.

Test Analytics

Every renmy test run is recorded under .nimbus/history/ whether or not you asked for it. renmy 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
renmy analytics

# Narrow the window
renmy 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
renmy 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 renmy 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. renmy history --flaky reports them out of the same run history the analytics view uses.

bash
renmy 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. renmy 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 renmy 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.