# Exit Codes and the JSON Report

> What each of the four stringlane exit codes means, the shape of the --json envelope, and the machine-readable refusal codes apply can return.

Source: https://stringlane.app/docs/reference/exit-codes
Last updated: 2026-09-14

## Exit codes

| Code | Meaning |
|---|---|
| `0` | No issues |
| `1` | The project has localization issues |
| `2` | The command could not run |
| `3` | Something `stringlane` depends on did not answer |

The separation of `1` and `2` is load-bearing. A missing `stringlane.yaml`, an unreadable file or a bad flag is not the same event as a genuinely broken translation, and a pipeline that treats them alike tells you the wrong thing on a bad morning.

`3` narrows `2` further, and today only one thing produces it: the npm registry gave no answer to `stringlane upgrade`. The command itself was fine and your project is not implicated, so calling it `2` would say you invoked it wrong. Nothing about your translations can produce a `3`, and neither can the background update check — that one leaves the exit code untouched whatever the registry does, so a `check` job never sees one.

`--fail-on` can only narrow which findings produce `1`. It cannot suppress a `2` or a `3`.

Per-command variations:

- **`plan`** exits `0` even when there is a great deal to translate, because a plan is a list rather than a gate. It exits `1` only when a file failed to parse, since a plan built from an unreadable file is incomplete rather than empty.
- **`validate`** exits `1` when the candidates have problems.
- **`apply`** exits `0` when everything was applied, `1` when the candidates have problems, `2` when it could not run — which includes every refusal below.
- **`upgrade`** exits `0` whether you are current or behind, since being behind is not an error, and `3` when the registry gave no answer.

## The JSON envelope

```sh
stringlane check . --json
```

```json
{
  "protocolVersion": "1",
  "command": "check",
  "status": "issues",
  "result": {
    "kind": "single-origin",
    "locales": ["de", "en", "fr"],
    "baseLocale": "en",
    "totalKeys": 8,
    "completionPercent": 96,
    "localeCoverage": [
      { "locale": "de", "totalCells": 9, "filledCells": 9, "missingCells": 0 },
      { "locale": "fr", "totalCells": 10, "filledCells": 9, "missingCells": 1 }
    ],
    "localizationFiles": [
      "res/values-de/strings.xml",
      "res/values-fr/strings.xml",
      "res/values/strings.xml"
    ],
    "parseErrors": [],
    "loadIssues": [],
    "configWasAbsent": false,
    "configState": "present",
    "format": "android-xml",
    "detectedConfig": { "format": "android-xml", "path": "res" },
    "issues": [
      {
        "key": "item_count",
        "locale": "fr",
        "severity": "error",
        "code": "missing_key",
        "message": "Plural quantity \"many\" for \"item_count\" is missing in \"fr\"",
        "quantity": "many"
      }
    ],
    "issueCounts": { "error": 1, "warning": 3, "info": 0 }
  },
  "warnings": []
}
```

### Envelope fields

| Field | Meaning |
|---|---|
| `protocolVersion` | Currently `"1"`. The contract to build against |
| `command` | Which command produced this |
| `status` | The outcome, matching the exit code family |
| `result` | The command's own payload, shaped per command |
| `warnings` | Non-fatal notes about the run itself, not about your translations |

### Coverage fields

Counts are **cells**, not keys. A plural key contributes one cell per quantity the target language requires, which is why a locale requiring `one`, `few`, `many` and `other` can show a higher `totalCells` than one requiring two forms. `completionPercent` is derived from cells across the project.

### Issue fields

| Field | Meaning |
|---|---|
| `key` | The translation key |
| `locale` | The locale the finding is about |
| `severity` | `error`, `warning` or `info` |
| `code` | Stable identifier — see the [validation codes](/docs/reference/validation-codes) |
| `message` | Human-readable, and the string the terminal prints |
| `quantity` | Present on plural findings: the CLDR category |
| `detail` | Present on some codes: what was expected and what was found |

`code` is the field to branch on. `message` is written for a person and is allowed to be reworded.

## Refusal codes

`apply` and the writing MCP tools report a machine-readable reason when they decline. Nothing is written on any of them.

| Code | Meaning | What to do |
|---|---|---|
| `STALE_PLAN` | A file the plan could touch changed since the plan was made | Re-run `stringlane plan` |
| `PROJECT_BUSY` | Another apply holds the per-project lock | Wait, or `--force-unlock` if you know the holder is dead |
| `PATH_OUTSIDE_PROJECT` | A path resolved outside the canonical project root | Check for a symbolic link in the repository |
| `INVALID_PROJECT` | The project could not be read as a StringLane project | Run `stringlane init`, or check `stringlane.yaml` |

`WRITE_FAILED` is returned when the write itself could not happen — the project directory is not writable, the filesystem is read-only, or there is no space. Nothing is written on it either.

Two issue codes, `unused_key` and `undefined_key`, are produced only by the source-code scan, so `check --no-scan --fail-on unused_key` is refused rather than accepted: with the scan off nothing can emit that code, and a gate that can never fire looks exactly like a clean project.

A candidate naming a key and locale the plan does not contain is refused rather than ignored. Quietly dropping an agent's typo produces a missing translation nobody ever notices.

## Frequently asked questions

### What does stringlane exit code 2 mean?

The command could not run: a missing or invalid config, an unreadable project, a bad flag. It is deliberately distinct from exit 1, which means the command ran and found localization issues, and from exit 3, which means the command was fine but something it depends on did not answer. A broken gate and a failing gate are different answers.

### Is the stringlane --json output stable?

The envelope carries protocolVersion "1" and is the contract to build against. Pin the CLI version in CI anyway: at 0.x the command surface is not promised, and the version number is how that is said.
