# `.claude/hooks/`

Project-local Claude Code hooks. Each script reads a tool-call payload from
stdin per the Claude Code hook protocol and emits structured output.

## `view-conventions-check.py` — `PostToolUse` linter for view files

Catches the project-convention mismatches identified in the OZ14-19219
retrospective (`getAttr+htmlspecialchars` instead of `printAttrValue`,
alternative-syntax `endif;` / `endforeach;`, missing `Strings::sieroty()`)
the moment a view file is edited or written.

### What it does

After every `Edit` / `Write` / `MultiEdit` tool call:

1. Resolves the touched file's project-relative path.
2. Loads `.claude/conventions/views.json` (the rule set authored in R3).
3. Runs each rule whose `files` glob matches the path.
4. Emits one of three outcomes:

   | Outcome | Stdout | Effect |
   |---|---|---|
   | No violations | (empty) | Tool call proceeds silently. |
   | `error`-severity match | `{"decision": "block", "reason": "..."}` | Claude is prompted to fix the violation on the next turn. |
   | `advisory`-severity match | `{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "..."}}` | Tool call proceeds; Claude sees the warning. |

The hook's exit code is always `0` for normal operation (success or
violations) and `1` only on internal error (caught and reported to stderr).

### Adding a new rule

Rules live in `.claude/conventions/views.json`. The hook reads them fresh
on every invocation — no caching, no code changes needed. To add a rule:

```json
{
  "id": "kebab-case-id",
  "title": "Short imperative title.",
  "severity": "error" | "advisory",
  "files": ["public/local/views/**/*.php"],
  "detect": [
    { "type": "regex", "pattern": "<your regex>",
      "skipIfContext": ["token1", "token2"] }
  ],
  "suggest": "What to do instead.",
  "reference": "docs/shared/<doc>.md#<anchor>"
}
```

Notes:

- `files` is a list of globs. `**` and `**/` cross directory boundaries; `*`
  matches a single segment.
- `detect` is a list — every entry that matches contributes violations.
- `skipIfContext` is a list of literal tokens; if any token appears on the
  same line as the regex match, the match is dropped. Use this for context
  the regex itself can't express (e.g. `sr-only` wrappers, helper-prefixed
  output).
- `type: "manual"` detect entries are recognized but produce no output —
  reserve them for AST-level rules the future subagent companion handles.
- After editing rules, run the test suite:
  `python3 .claude/hooks/view-conventions-check.test.py`.

### Suppressing a rule on a specific line

A trailing comment on the violating line silences the named rule:

```php
<?= $debugString ?> // lint-skip:use-sieroty
```

```html
<span class="header-debug-id"><?= $debugString ?></span> <!-- lint-skip:use-sieroty -->
```

Rules:

- The directive must be **at the end of the line**, after any code.
- The directive must name the **specific rule id(s)** to suppress; bare
  `// lint-skip` (no rule id) is ignored.
- Multiple ids on one line: `// lint-skip:use-sieroty,use-braces`.
- Cross-line suppression is not supported.
- When the hook reports any other violation, suppressed rules are listed
  under `[skip]` so Claude doesn't get a surprise silent pass.

### Tests & performance

Run unit and subprocess tests with:

```bash
python3 .claude/hooks/view-conventions-check.test.py
```

The benchmark inside the test suite asserts a full hook run completes in
under 200 ms on a typical view file.

### Out of scope (deferred to a follow-up subtask)

- AST-level rules such as `early-return-empty` — reserved for a manual
  `view-conventions-reviewer` agent.
- Auto-fixing violations.
- A parallel `models.json` ruleset / sibling models hook.
