# Validation checks

> What stacks validate checks in an entry, the word budgets, finding severities, exit codes and the --brief preview.

`stacks validate` checks stacks and fragments before an agent builds from them, and `stacks validate --brief` previews the brief a composition renders to. It reads files only and runs nothing from an entry. For how to write an entry that passes, see [Write your own stack or fragment](https://stacks.steamfield.io/docs/guides/authoring.md).

```sh title="Terminal"
stacks validate                                         # the bundled registry
stacks validate ~/.config/stacks                        # a registry root (holds stacks/ or fragments/)
stacks validate ~/.config/stacks/stacks/my-api --json   # one entry, as a ValidateReport
stacks validate --brief typescript-cli --sandbox        # the brief a composition renders to
```

A relative path resolves against the directory you ran the command from. With no path, `validate` checks the bundled registry. The flags are listed under [stacks validate](https://stacks.steamfield.io/docs/reference/commands.md#stacks-validate).

```sh title="Terminal" group="validate"
stacks validate
```

```text title="Output" group="validate"
...
fragment docker-deploy
  note     conditional-dead-branch  fragments/docker-deploy/FRAGMENT.md:53: stack=astro-fullstack is reachable only when docker-deploy is added with --with

fragment drizzle-postgres
  note     conditional-dead-branch  fragments/drizzle-postgres/FRAGMENT.md:54: stack=nextjs is reachable only when drizzle-postgres is added with --with
...

14 stacks, 23 fragments: 0 errors, 0 warnings, 20 notes
Next: stacks validate --brief astro-blog --sandbox
```

Each finding names its severity, a stable check id, the file and line, and a message. Findings are grouped by entry, with registry-wide findings first.

## What validate checks

`validate` runs these passes over every entry under the root:

1. **Loading.** The frontmatter decodes against the [entry format](https://stacks.steamfield.io/docs/reference/entry-format.md), the id matches the directory, the kind matches the file name, and no symlinks are present.
2. **Frontmatter rules.** Decisions name a default, `deps` carry no versions, step names are unique and have a layer, and a stack has exactly one task-runner tag and a `## Setup` section.
3. **References.** Every id in `fragments`, `requires`, `conflicts` and `applies_to` resolves, and conditional markers are well formed and reachable.
4. **Compositions.** Each stack is composed in both presets: alone (with its required and default fragments), with each optional fragment and each fitting app added on its own, then with each compatible pair of those extras. Each composition is checked for conflicts, word budget and verify coverage.
5. **Files.** `merge` paths exist and hold JSON, templates use known variables, git doesn't ignore any shipped file, and shipped config files reference real commands, hooks and imports.
6. **Prose.** Pinned versions, dated phrases, version-sensitive config files and setext headings.
7. **Search and dependency cards.** Search cards decode, match their entry, are current and fit their size limits. In a registry with `allowlist.json`, dependency cards are checked against it.
8. **Run ledger.** In a registry with `runs.json`, notes on stacks whose recorded runs are missing, stale or failing.

References resolve through every registry root, the one being validated first. A reference that resolves only in another root is a note naming that root. The bundled registry must be self-contained.

### Compositions and coverage

Pairs of two apps, and pairs whose fragments conflict with each other, are skipped. Each addition drops any default fragment it replaces, following the same `--without` hint `new` gives. Size rows label each composition with `with`: one fragment id for a single addition, or two sorted ids joined by ` + ` for a pair. An over-budget pair finding includes the `validate --brief` command that previews it.

Every stack's base composition must verify each layer: `install`, `lint`, `typecheck` and `test`, plus a `serve` step with a probe when the stack is tagged `web` or `api` (`verify-layers`). Verify commands must be declared in that composition's `commands` (`undeclared-command`), and a fragment's commands need verify coverage (`ref-command-coverage`).

`validate` also tries each explicitly applicable fragment beside each stack's defaults when the composition pass hasn't already covered that combination (`compose-each`). Declared conflicts replace default fragments in either direction; required fragments stay.

### References in shipped files

Reference checks read the rendered, merged files for every shipped adapter. Claude Code settings permissions and VS Code launch arguments must name composed commands or commands named in inline code (`ref-command`). Hook paths must exist in the composed files (`ref-hook`). Literal localhost ports in settings or VS Code JSON warn when an env variable has a `PORT` segment (`ref-port`). A reference file that isn't valid JSON or TOML is one error for that file, and its other reference checks are skipped.

Shipped JavaScript and TypeScript relative imports, and shell `source` or `.` targets, must resolve to composed files owned by the same entry or a dependency (`import-closure`). A fragment may import through everything it `requires`, transitively; a stack may import through `fragments.required`. Default and optional fragments are removable, so they don't grant import access. Resolution maps `.js` to `.ts` or `.tsx`, tries `.ts`, `.tsx`, `.js` and `.mjs` for extensionless imports, and tries `index.ts` for directories. In shell paths, `$CLAUDE_PROJECT_DIR` means the project root; another leading variable means the script's directory.

## Check ids

The check id is stable, so it's safe to match on in `--json` output (`findings[].check`).

| Check | Severity | What it means |
| --- | --- | --- |
| `schema` | error | The frontmatter doesn't decode, isn't YAML, or is missing |
| `symlink` | error | The entry is or contains a symlink |
| `id-matches-dir` | error | `id` differs from the directory name |
| `kind-matches-file` | error | `kind` differs from the file name |
| `decision-default` | error | A decision has no `(default ...)` choice |
| `dep-version` | error | A `deps` name carries a version |
| `step-names` | error | Two verify steps share a name |
| `step-layer` | warning | A verify step has no `layer` |
| `task-runner-tag` | warning | A stack has neither or both of the `make` and `scripts` tags |
| `stack-setup-section` | warning | A stack's body has no `## Setup` heading |
| `unknown-reference` | error | A referenced fragment or stack doesn't exist |
| `resolved-outside-root` | note | A reference resolves only in another registry root |
| `conditional-syntax` | error | A malformed, nested or unclosed conditional marker |
| `conditional-ids` | error | A marker names an id that doesn't exist |
| `conditional-tag` | warning | A `tag=` or `tag!=` marker names a tag no stack declares |
| `conditional-dead-branch` | warning or note | No composition reaches the branch; a note when only `--with` reaches it |
| `compose` | error | A composition fails: a conflict, a missing id or contradictory flags |
| `optional-fits` | error | An optional fragment doesn't fit the stack that lists it |
| `compose-each` | error | A stack plus an applicable fragment fails to compose |
| `verify-layers` | error | A base composition is missing a required verify layer |
| `budget-body` | error | An entry body is over its word budget |
| `budget-composed` | error | A composed brief is over its word budget |
| `undeclared-command` | warning | A verify step calls a command not in `commands` |
| `ref-command-coverage` | warning | A fragment's command has no verify coverage |
| `merge-path-missing` | error | A `merge` path isn't shipped by the entry |
| `merge-json` | error | A merged file isn't valid JSON |
| `template-expression` | warning | A bare Actions expression such as `${{ github }}` in a `.tmpl` file |
| `git-ignored` | warning | Git ignores a shipped file, so it would never reach a commit |
| `ref-command`, `ref-hook` | error | A shipped config names a command or hook that isn't composed |
| `ref-json`, `ref-toml`, `ref-config` | error | A shipped reference file can't be parsed or has the wrong structure |
| `ref-port` | warning | A literal localhost port where an env variable exists |
| `import-closure` | error | A shipped import reaches a file outside the entry's dependencies |
| `pinned-version` | warning | Prose names an exact version such as `1.2.3` |
| `dated-phrase` | warning | Prose ties itself to a release: "as of 2026", a month and year, `rc`, `alpha`, `nightly`, `canary` |
| `version-sensitive-file` | warning | A shipped config file that changes between tool releases; describe it in prose instead |
| `setext-heading` | warning | A heading underlined with `===` or `---`; use `##` |
| `search-invalid` | error | A search card doesn't decode |
| `search-orphan` | error | A search card has no matching entry |
| `search-missing` | warning or note | No search card: a warning for bundled entries, a note for yours |
| `search-stale` | warning or note | The card predates the entry's current text. A note for an unreviewed author draft (`by: "author"`, `reviewedAt: null`), which has no reviewed version to go stale against |
| `search-unreviewed` | warning | Nobody has reviewed the card |
| `search-size`, `plain-versioned` | warning | Card copy is too long, or plain copy names a version |
| `vocabulary-invalid` | error | A card uses a value outside the search vocabulary |
| `vocabulary-ignored` | note | A non-bundled root tries to redefine the vocabulary |
| `deps-invalid`, `deps-ambiguous` | error | A dependency card can't be read or isn't on the allowlist, or a name is ambiguous |
| `deps-missing`, `deps-unreviewed` | warning | A dependency has no card, or its card needs review |
| `deps-review-due` | note | A dependency card's review is over 365 days old |
| `authors-invalid`, `authors-orphan` | error | A registry's `authors.json` doesn't decode, or names an entry that isn't in that registry |
| `ledger-schema` | error | `runs.json` is malformed; the other ledger notes are skipped |
| `ledger-unproven`, `ledger-changed`, `ledger-failed` | note | A stack has no sandbox pass, changed since its last pass, or its latest run failed |

Only default compositions count for ledger notes, and a missing `runs.json` is normal.

## Word budgets

Budgets count every conditional branch in a body, since any of them can be rendered. For a composed brief they also count the CLI's own text, reported separately as fixed text. Every composed brief is rendered on one fixed context (no missing tools, a fresh repository, the `npx` invocation), so the counts match on every machine.

| Subject | Limit (words) |
| --- | --- |
| Stack body | 2,000 |
| Fragment body | 1,000 |
| App body (`category: app`) | 3,000 |
| Composed brief | 6,000 |
| Composed brief with an app | 9,000 |

Markdown shipped under `files/` and `adapters/` is counted and reported but has no limit. When a composition runs over, move optional material into a fragment, move reference material into a shipped doc the body links to, and cut what the agent would do anyway.

## Severities and exit codes

Findings are errors, warnings or notes. Only errors fail the command.

| Exit | When |
| --- | --- |
| 0 | No errors. Warnings and notes don't change the exit |
| 2 | Errors (`ValidationFailed`). The full report is still on stdout, and the last stderr line is `Validation failed.`, or with `--json` the error envelope whose `details` carry the counts |
| 2 | A path that is neither a registry root nor an entry under `stacks/` or `fragments/` (`NotFound`), or `--with`, `--without` or `--sandbox` without `--brief` |
| 3 | A broken `config.json` with `--brief` |
| 6 | The root can't be read |
| 130 | Interrupted |

The bundled registry must pass with zero warnings as well as zero errors. Your own entries pass with warnings, though most warnings flag text that will go stale or confuse the agent.

## Previewing a brief

`--brief <stack>` prints only the composed brief to stdout, exactly as `new` would render it on the fixed budget context. In human mode the word count, fixed-text count and limit go to stderr; agent output keeps stderr empty on success.

```sh title="Terminal"
stacks validate --brief go-cli --sandbox
stacks validate --brief go-cli --with goreleaser --without ci-github
stacks validate ~/.config/stacks --brief my-api --json
```

`--with` and `--without` take ids, repeatable or comma-separated. They and `--sandbox` work only with `--brief`. The preview uses only the first configured agent (default `claude`), ignores the `always` and `preset` settings, writes nothing and starts no processes.

`--json` returns `BriefPreview` (schema `validate-brief`): the stack, preset, fragments in composition order, the app, `briefWords`, `fixedWords`, `limit`, composition `warnings`, the `brief` text, its `fingerprint`, and findings for the selected entries. Error findings exit 2 with `ValidationFailed` and keep the brief on stdout.
