# pa lint linkedin

Deterministic voice-rule linter for operator-authored LinkedIn text. Loads a
JSON rule corpus and reports voice-rule violations in **alter-format** — each
finding tells the writer what to do (`swap → X`, `cut`, `rename`) rather than
just flagging a banned token.

## Why this exists

Voice rules that are machine-checkable should be machine-checked, not left to a
prompt instruction the writer mentally verifies against. Three voice failures
shipped in one week despite a pre-delivery checklist (a forced "isn't X, it's Y"
contrast, a `the right cut` reaction phrase reused across sibling replies, an
`Indeed..` opener repeated across replies). This linter moves those rules into a
validator. The framing is deliberately **alter, not block**: the writer is more
reliable picking from a known target than avoiding a blacklist, so every rule
carries its swap / cut / rename guidance inline.

## Usage

```sh
pa lint linkedin <input-file> [options]
```

| Option | Default | Meaning |
|---|---|---|
| `--rules <path>` | `operator/knowledge/writing/linkedin-rules.json` under the repo root | Rule corpus JSON. Required if the default file is absent. |
| `--format <text\|json>` | `text` | Output format. |
| `--severity <error\|warning\|info>` | `error` | Severity floor — only violations at or above this level are reported. |
| `--surface <name>` | `linkedin` | Surface context: `all`, `linkedin`, `article`, `newsletter`, `reply`. A rule applies if its `surface` is `all` or includes this value. |
| `-h, --help` | | Show help. |

The default rules path resolves relative to the operator repo root (located via
`$DAVE_REPO_ROOT` / `$PA_REPO_ROOT`, else by walking up to the nearest `.git`).
If it is absent and `--rules` is not given, the command exits 3 with guidance.

### Text output

One line per violation:

```
<file>:<line>:<col>  <severity>  <ruleId>  <matched-text>  →  <alter-guidance>
```

Example:

```
post.md:1:14  error  punct-em-dash  "—"  →  swap → "," | " - " — Comma or hyphen carries the load.
post.md:2:7   error  vocab-utilize  "utilize"  →  swap → "use" — Plain word.
```

### JSON output

`--format json` prints an array (`[]` when clean — always valid JSON). Each
entry:

```json
{
  "file": "post.md",
  "line": 1,
  "col": 14,
  "ruleId": "punct-em-dash",
  "severity": "error",
  "matched": "—",
  "alter": "swap → \",\" | \" - \" — Comma or hyphen carries the load.",
  "rationale": "Hard AI tell. Operator-mandated permanent rule.",
  "source": "operator/knowledge/writing/voice-guide.md"
}
```

## Exit codes

This command uses **lint-semantic exit codes** (ESLint/ruff-style), where the
code reports the worst severity among the *reported* (post-floor) findings:

| Code | Meaning |
|---|---|
| `0` | Clean — no findings at/above the floor (info-only also exits 0) |
| `1` | At least one `error` finding |
| `2` | `warning` findings only (no errors) |
| `3` | Usage / IO / config error (bad flag, missing file, malformed corpus) |

> **Note — deviation from tooling standard §6.** The platform standard reserves
> exit `1` for usage errors and `2`/`3` for operational/transport failure. A
> linter's exit code is load-bearing differently: automation needs to branch on
> *what was found*, not just *did the tool run*. So this command follows the
> universal linter convention (`1` = findings, higher = clean-vs-broken) and
> maps every non-finding failure to `3`. This is the only `pa` verb that
> deviates, and it does so intentionally.

## Rule corpus schema

The corpus is a JSON array of independent rule objects (order does not matter).
The loader validates strictly — a malformed corpus is a config error (exit 3),
never a silent skip.

```jsonc
{
  "ruleId": "punct-em-dash",          // kebab-case, unique across the corpus
  "category": "punctuation",          // punctuation | vocabulary | phrase |
                                      //   reaction-phrase | filler | structural |
                                      //   format | count
  "surface": ["all"],                 // any of: all linkedin article newsletter reply
  "severity": "error",                // error | warning | info
  "detect": { /* see below */ },
  "alter":  { /* see below */ },
  "rationale": "Hard AI tell.",       // why the rule exists
  "source": "operator/knowledge/writing/voice-guide.md"  // human-readable home
}
```

### `detect`

| `type` | Fields | Behaviour |
|---|---|---|
| `literal-phrase` | `phrase`, `caseSensitive` (default `false`), `wordBoundary` (default `true`) | Matches the phrase as text. Word boundaries are applied at word-character edges only. |
| `regex` | `pattern`, `caseSensitive` (default `false`) | Matches the pattern. Scanned line-by-line, so positions are exact and negated classes can't swallow newlines. |
| `count` | `pattern`, `max` | Counts matches document-wide; flags every occurrence **beyond** `max`. Use `(?:^\|\n)…` anchoring to count line openers. |

### `alter`

| `kind` | Fields | Rendered as |
|---|---|---|
| `swap` | `with` (array of replacement strings), `guidance` | `swap → "a" \| "b" — guidance` |
| `cut` | `guidance` | `cut → guidance` |
| `rename` | `guidance` (open-ended rephrase instruction) | `rename → guidance` |

## Examples

```sh
# Lint a draft against the default operator corpus.
pa lint linkedin drafts/post.md

# Lint a reply, surfacing warnings too, as JSON.
pa lint linkedin drafts/reply.md --surface reply --severity warning --format json

# Lint against an explicit corpus (e.g. in CI or a fixture).
pa lint linkedin post.md --rules path/to/linkedin-rules.json
```
