# `PromptRunner.PacketLint`
[🔗](https://github.com/nshkrdotcom/prompt_runner_sdk/blob/v0.12.1/lib/prompt_runner/packet_lint.ex#L1)

Static authoring hazards in a packet.

`packet doctor` reports authoring *gaps* — a packet with no prompts, a prompt
with no targets. `packet lint` reports authoring *hazards*: constructs that
load, run, and produce a wrong answer without ever raising. Every check below
corresponds to an observed way a packet silently misbehaves.

## Errors

Findings that make the packet mean something other than what it says. Each
one exits non-zero.

- `prompt_id_filename_mismatch` — prompts are ordered by the numeric filename
  prefix, the sort key built by `PromptRunner.Source.DirectorySource`, not by
  `id:`. A mismatch reorders the run while the front matter still reads
  correctly.
- `prompt_filename_without_prefix` — with no numeric prefix the file sorts
  last, by basename, among all other unprefixed files.
- `duplicate_prompt_id` — two prompts claiming one id collide in progress
  state, runtime state, and `run <packet> <id>` selection.
- `unknown_target_repo` — a target that names no manifest repo contributes no
  working directory and no verifier scope.
- `unknown_verify_repo` — a verify entry scoped to a repo that does not exist
  resolves against nothing.
- `repo_group_in_targets` — `@group` syntax is a legacy-config feature.
  `PromptRunner.RepoTargets` is never consulted with packet repo groups, so
  the target expands to nothing.
- `unknown_verify_clause` — an unrecognized key under `verify:` is parsed,
  stored, and never evaluated, so the contract is weaker than it reads.

## Warnings

Findings that are usually wrong but legitimately intentional sometimes. They
exit zero unless `strict: true` promotes them.

- `legacy_shell_command` — a string/`run:` command still asks the legacy
  compatibility path to interpret shell syntax. Strict packets reject it.
- `verify_command_without_timeout` — a legacy command has no explicit GNU
  timeout wrapper, or a structured command omits `timeout_ms`.
- `prompt_without_verify` — completion falls back to the provider's own claim
  of success.
- `contract_without_commands` — the contract has neither a `commands:` entry
  nor any content assertion (`contains`, `matches`, `doc`), so it is
  satisfied by an empty file.
- `changed_paths_only_vacuous` — `changed_paths_only` reads
  `git status --porcelain`, so it can only see work that is still
  uncommitted. It passes vacuously in any packet where the session commits
  its own work.
- `inert_front_matter_key` — `references`, `required_reading`, and
  `context_files` are parsed and stored and then never read at runtime.

Packet lint is intentionally independent of the current checkout state. A
relative verifier executable may be an output of this prompt or an earlier
dependency, so existence and executability are evaluated only by the
post-session verifier.

## Legacy timeout detection

A command counts as bounded when any of its segments — split on `&&`, `||`,
`;`, and `|` — begins with a `timeout` token. Lint checks that `timeout` is
invoked at all, not that every branch of a compound command is bounded;
deciding the latter needs a shell parser.

# `finding`

```elixir
@type finding() :: %{
  :kind =&gt; String.t(),
  :severity =&gt; String.t(),
  :message =&gt; String.t(),
  :prompt_id =&gt; String.t() | nil,
  :file =&gt; String.t() | nil,
  optional(:key) =&gt; String.t()
}
```

# `report`

```elixir
@type report() :: %{
  packet: String.t(),
  root: String.t(),
  strict?: boolean(),
  findings: [finding()],
  errors: non_neg_integer(),
  warnings: non_neg_integer(),
  pass?: boolean()
}
```

# `lint`

```elixir
@spec lint(
  String.t(),
  keyword()
) :: {:ok, report()} | {:error, term()}
```

Lints the packet rooted at `root`.

Options:

- `:strict` — promote every warning to an error.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
