# Slop-Gate — Global Engine + Layered Rules Architecture

**Date:** 2026-06-10
**Status:** Design
**Goal:** A single reusable code-quality / anti-slop gate that hooks agent edits (PostToolUse) and commits (PreToolUse) across 10+ projects, where the **engine is global and auto-latest** but **rules are owned and pinned per project**, on top of a **shared baseline ruleset** every project inherits.

Ported and generalized from multideal's `apps/web/scripts/code-quality/` (engine: `scan.mjs`, `ast-engine.mjs`, `suppressions.mjs`, hooks; project-coupled rules: `patterns.mjs`).

---

## Decisions (user-locked)

1. **Reach:** many/most projects (10+). Reuse pressure is high — a shared engine pays off.
2. **Update model:** **hybrid** — the engine auto-tracks latest (one global source); the rule-set is pinned/owned per project (no rule fires in a project until that project opts in).
3. **Rule layering:** **global baseline + local** — a shared baseline pack (no-stubs, `as any`, `@ts-ignore`, hardcoded hex, KV-ban, etc., already encoded in global CLAUDE.md) is inherited by every project, plus per-project rule packs on top.
4. **False positives:** persistent per-project `suppressions.json`; gate fires → agent suspects FP → asks user (AskUserQuestion) → on approval, entry appended. (Mirrors multideal v2.)
5. **Legacy policy:** zero-tolerance per rule — a new rule ships with a cleanup wave that drives repo hits to 0 BEFORE it is enabled in blocking mode.

---

## Architecture

Three layers, cleanly separated by **what changes and who owns it**:

```
┌─ GLOBAL (auto-latest, one source of truth) ──────────────────────────┐
│ ~/Projects/slop-gate/            (own git repo; engine = runtime tool) │
│   bin/slop-gate                  CLI entry (node ESM)                  │
│   src/                                                                 │
│     gate.mjs        orchestrator: runGate(mode, config) → collect+print│
│     regex-engine.mjs  ripgrep catalog runner (bucket A)               │
│     ast-engine.mjs    @ast-grep/cli runner (bucket B), graceful-degrade│
│     suppressions.mjs  load/match/prune approved FPs                   │
│     config.mjs        resolve + validate a project's .slop-gate config │
│     init.mjs          `slop-gate init` — stack-aware project bootstrap│
│   rules/baseline/     GLOBAL baseline rule packs (versioned in repo)  │
│     no-stubs.mjs, ts-suppress.mjs, as-any.mjs, raw-hex.mjs, kv-ban.mjs │
│     ast/*.yml         baseline ast-grep rules                         │
│   hooks/                                                               │
│     edit-hook.sh      PostToolUse Edit|Write → `slop-gate --file`     │
│     commit-hook.sh    PreToolUse git-commit → `slop-gate --staged`    │
└───────────────────────────────────────────────────────────────────────┘
            │ referenced by path/bin, NOT vendored
            ▼
┌─ PER PROJECT (pinned, lives in the project's git) ───────────────────┐
│ <project>/.slop-gate/                                                 │
│   config.mjs          the ONLY project-coupling point (see schema)    │
│   rules/                                                              │
│     *.mjs             project regex packs (cite project utils/paths)  │
│     ast/*.yml         project ast-grep rules                          │
│   suppressions.json   approved false positives (per project)         │
│   fixtures/           canary files proving rules fire (self-test)     │
│   convention-sources.json  manifest of CLAUDE.md/skills/rules docs    │
│                             for agent-driven project rule authoring   │
└───────────────────────────────────────────────────────────────────────┘
            │ wired via the project's own .claude/settings.json hooks
            ▼
   PreToolUse(git commit) → slop-gate --staged  --config .slop-gate/config.mjs
   PostToolUse(Edit|Write) → slop-gate --file $P --config .slop-gate/config.mjs
```

**Invariant:** the engine contains zero project knowledge. Every project-specific path, util name, severity gate, and enabled baseline pack lives in `.slop-gate/config.mjs` or the project's own rule files. This is what makes the engine globally shareable and auto-latest-safe.

---

## Component 1 — Engine (`gate.mjs` orchestrator)

`runGate(mode, resolvedConfig) → { violations, exitCode }`. Modes (carried over from multideal v2):

| Mode | Trigger | Engines | Scope | Blocking |
|------|---------|---------|-------|----------|
| `--file <p>` | PostToolUse Edit\|Write | regex + ast | one file | exit 2 → agent feedback (critical/high) |
| `--staged` | PreToolUse git commit | regex + ast + redef | staged src | exit 1 → commit blocked (critical/high) |
| full audit | `/zc-slopgate` skill / `scan:quality` | all phases + jscpd | full src | report; loop until critical/high = 0 |
| `--self-test` | CI / pre-enable | regex + ast over `fixtures/` | fixtures | exit 0 iff every rule's canary fires |

Engine responsibilities only: file enumeration (respecting config globs + `--staged`/`--file`), dispatch to regex+ast engines, apply suppressions, collect into the shared violation shape, print human + `--json` output, compute exit code from config severity gate. **No rule definitions live in the engine** except the baseline pack directory (which is data, not engine logic).

**Shared violation shape** (stable contract between engines, suppressions, printer):
```
{ id, severity, category, file, line, text, resolution, source: 'regex'|'ast'|'redef' }
```

---

## Component 2 — Config schema (`.slop-gate/config.mjs`)

The single project-coupling seam. Example (zync.is):

```js
export default {
  // file enumeration — globs relative to repo root
  roots: ['apps/zync-app/src', 'apps/zync-www/src', 'packages/*/src'],
  exts: ['.ts', '.tsx', '.astro'],
  skipDirs: ['node_modules', 'dist', 'tests', '.worktrees'],

  // baseline packs this project OPTS INTO (hybrid: nothing fires until listed)
  baseline: ['no-stubs', 'ts-suppress', 'as-any', 'raw-hex', 'kv-ban'],

  // project-owned rule packs (pinned, in this repo)
  rules: ['./rules/no-raw-drizzle.mjs', './rules/oklch-only.mjs', './rules/no-html-table.mjs'],
  astRules: './rules/ast',           // dir of *.yml

  // severity gate per mode
  gate: { file: ['critical', 'high'], staged: ['critical', 'high'] },

  suppressions: './suppressions.json',
  fixtures: './fixtures',
}
```

`config.mjs` resolution (`config.mjs` engine module): merge selected baseline packs + project packs, validate every rule object shape, dedupe by `id`, fail loudly on unknown baseline pack name. Baseline packs are **opt-in by name** — satisfies "no rule fires until the project opts in."

---

## Component 3 — Rule pack format (unchanged from multideal)

Two buckets, reused verbatim so multideal rules port without rewrite:

- **Bucket A (regex):** `patterns.mjs`-style objects — `{ id, title, category, severity, pattern, description, resolution, canonicalImport?, excludeGlobs?, minFiles?, userVisible? }`. ripgrep-backed.
- **Bucket B (ast-grep):** one `*.yml` per rule, metadata JSON in `note:` field (severity/resolution/category), `severity: error|warning` mirrors blocking/non-blocking.

A baseline pack is just a module exporting an array of bucket-A objects (and/or a dir of bucket-B yml). A project pack is identical shape — no distinction at the engine level.

---

## Component 4 — Baseline rule packs (global universals)

Sourced from the user's global CLAUDE.md standing rules (apply to every project):

| pack id | catches | source |
|---------|---------|--------|
| `no-stubs` | `for now`, `in a real app`, `placeholder`, `TODO: implement`, `not implemented` | global "No stubs/placeholders/workarounds" |
| `ts-suppress` | `@ts-ignore`, `@ts-expect-error`, `eslint-disable` | agentic anti-pattern |
| `as-any` | `as any\b` | agentic anti-pattern |
| `raw-hex` | `#[0-9a-fA-F]{3,8}` in source (outside token files) | design-token discipline |
| `kv-ban` | `env\.KV`, `KV_NAMESPACE`, `\.kv\.` | global avoid-KV preference |
| `inner-html` (ast) | `.innerHTML =`, `insertAdjacentHTML(` | security |
| `empty-catch` (ast) | empty/comment-only catch body | swallowed errors |

Each baseline pack ships with its own fixture canary in the global repo's `rules/baseline/fixtures/` so `slop-gate --self-test` validates the baseline independent of any project.

---

## Component 5 — Per-project rule packs (zync.is initial set)

Mechanize the zc-* skills' learned rules that are statically detectable:

| rule id | catches | owning skill |
|---------|---------|--------------|
| `no-html-table` | `<table>`/`<thead>`/`<tr>`/`<td>` in `.tsx` outside PDF/print allowlist | zc-ui-dev |
| `oklch-only` | hex/rgb/hsl in UI source (project-scoped severity) | zc-ui-dev |
| `no-raw-drizzle-route` | drizzle import inside `routes/**` | zc-dba |
| `neon-dot-query` (ast) | `.query(` on a neon http client binding | zc-dba (neon-http-tagged-template) |
| `zync-ui-flat-import` (ast) | flat `DialogContent` import from `@zync/ui` (compound API) | zc-ui-dev |

These are pinned in `zync.is/.slop-gate/rules/`. They reference zync paths/utils — exactly the coupling that must NOT leak into the global engine.

---

## Component 6 — Hook wiring (per project `.claude/settings.json`)

Each project opts in via hooks that call the global bin with its own config. Engine auto-latest because the bin resolves to the global repo; rules pinned because `--config` points at the in-repo `.slop-gate/`.

**`slop-gate init` wires hooks safely:** if `.claude/settings.json` is absent it is created with PreToolUse/PostToolUse entries; if present, existing hook arrays are deep-merged (our commit/edit hooks appended, never clobbering entries like `code-review-graph`). Idempotent — duplicate command strings are skipped; a `.bak` is written before any merge.

```jsonc
{
  "hooks": {
    "PreToolUse": [{ "matcher": "Bash",
      "hooks": [{ "type": "command",
        "command": "slop-gate-commit-hook" }] }],
    "PostToolUse": [{ "matcher": "Edit|Write",
      "hooks": [{ "type": "command",
        "command": "slop-gate-edit-hook" }] }]
  }
}
```

`slop-gate-commit-hook` / `slop-gate-edit-hook` are the global `hooks/*.sh` (on PATH via the global bin install). They read tool JSON from stdin, locate `./.slop-gate/config.mjs` from the repo root, and exec `slop-gate --staged|--file --config …`. Commit hook only fires on `git commit`/commit helper commands (multideal logic, generalized).

**Bootstrap caution (learned from multideal v2):** `--staged`/`--file` modes must WARN, not block, when `@ast-grep/cli` is absent — a gate must never brick commits on a missing optional tool. Regex engine still gates.

---

## Update / ownership model (the hybrid answer, concretely)

- **Engine = auto-latest.** Global `~/Projects/slop-gate` repo; `slop-gate` bin on PATH. A `git pull` (or a global `slop-gate self-update`) updates every project's engine at once. Engine is project-agnostic, so this is safe.
- **Baseline rules = global but opt-in.** Live in the engine repo, but a project only runs the packs it names in `config.baseline`. A newly added baseline pack does NOT fire anywhere until a project adds it — no surprise commit-blocking.
- **Project rules = pinned.** Live in the project repo under version control. Updated deliberately, per project, with their own cleanup wave.

This is precisely "engine auto, rules pinned" with no version-pinning ceremony.

---

## Migration plan (high level — detailed in the plan phase)

1. **Extract engine** from multideal `apps/web/scripts/code-quality/` into `~/Projects/slop-gate/` (own repo). Strip multideal-specific patterns out of `patterns.mjs` into a multideal project pack.
2. **Generalize** file enumeration, config resolution, severity gate (remove hardcoded `apps/web/src`, `REPO_ROOT` assumptions → driven by `config.roots`).
3. **Author baseline packs** from global CLAUDE.md universals + fixtures + self-test.
4. **Re-point multideal** to consume the global engine via `.slop-gate/config.mjs` (multideal becomes the first consumer; proves the extraction is faithful — its existing gate output must match pre-extraction).
5. **Bootstrap zync.is**: `slop-gate init` → scaffold `.slop-gate/` (stack-aware roots/exts/skipDirs), emit `convention-sources.json`, safe-merge hooks, author the 5 zync project rules + cleanup waves to 0 hits.
6. **`slop-gate init` command** for any new project: detects monorepo workspace `src/` roots, scans present extensions, conditionally adds build-artifact skipDirs, writes a populated `config.mjs` (safe baseline only), emits `convention-sources.json` (CLAUDE.md/skills/editor-rules manifest for rule authoring), and safe-merges `.claude/settings.json` hooks — idempotent, never overwrites an existing config.

---

## Error handling / degradation

- ast-grep binary missing → ast phase returns `{ available:false }`, loud stderr warning in report mode, WARN-not-block in `--file`/`--staged`. Regex engine still gates.
- Unknown baseline pack name in config → hard fail at config-resolve (typo protection).
- No `.slop-gate/config.mjs` found → hook exits 0 with a one-line "slop-gate not configured for this project" notice (a project without the gate must not be blocked).
- Malformed rule object → config-resolve throws with the offending `id`/file.

---

## Testing

- **Self-test:** every rule (baseline + project) has a fixture canary; `slop-gate --self-test` fails if any canary stops firing (rule rot detection). Baseline canaries live in the engine repo; project canaries in `.slop-gate/fixtures/`.
- **Extraction fidelity:** multideal's gate output post-extraction must equal pre-extraction on the same commit (golden-file test).
- **Per-rule cleanup-to-zero** before enabling in blocking mode (zero-tolerance policy).

---

## Architecture Decisions

Modules evaluated (deletion / single-adapter / seam audit):

- **regex-engine vs ast-engine kept separate (deep seam, KEEP):** two genuinely different implementations (ripgrep process vs ast-grep process + JSON parse), each hides non-trivial complexity behind the shared violation shape. Two real adapters exist → not decorative.
- **config.mjs resolver (medium, KEEP):** earns its boundary via deletion test — without it, baseline-merge + validation + path resolution scatters into every hook and the orchestrator. Single point of project-coupling.
- **suppressions module (medium, KEEP):** stable `isSuppressed(finding)` interface hides hash/match/prune internals; proven valuable in multideal.
- **baseline packs as data, not code (decision):** baseline rules are plain rule-object arrays in the engine repo, NOT a plugin system. Rejected a plugin/loader abstraction (single-adapter test fails — there is exactly one consumer shape). Collapsed into the same rule-pack format projects use.
- **init.mjs (shallow, KEEP anyway):** stack-aware scaffolder (monorepo root detection, ext/skipDir inference, convention-sources manifest, safe settings.json merge); shallow but justified — it is the documented on-ramp for 10+ projects and removes per-project setup error without clobbering existing Claude hooks.
- **Rejected: published npm package (Approach 3)** — version-pinning contradicts the locked "engine auto-latest" decision and the standing "Git = runtime only / dev-deps uncommitted" rules. One-way-door overhead unjustified.
- **Rejected: vendored per-project copy (Approach 1)** — fails the "engine auto" decision; N-way drift at 10+ projects.

---

## Open follow-ups (not blocking this design)

- Optional `advisor()`-based FP approval instead of AskUserQuestion (multideal v2 future note).
- LLM-judge audit phase (bucket C) — defer; add as a `/zc-slopgate` skill step once the static engine is live, identical to multideal's `/md-slopgate`.

> **2026-06-10 amendment** (per `2026-06-10-slop-gate-zync-hardening-design.md`): the tsc
> checker accepts `tsconfig: string | string[]` (monorepo) with PATH-tsc fallback, and the
> engine self-test now asserts every *project* ast rule fires on the project fixtures and
> FAILs on dangling `roots`/`fixtures` paths.
