Skip to content

Semantic (AI) rules

Most conventions are static and belong in the deterministic tier, that's what keeps Shapelint fast, reproducible, and safe to block a build. A few conventions genuinely resist encoding: "is this component doing too much?", "does this name describe its behavior?", "is there business logic in a primitive?". Those are what the semantic tier is for.

It is deliberately kept at arm's length from the deterministic gate:

  • Provider-agnostic. Shapelint bundles no AI SDK. You supply a judge.
  • Opt-in. Semantic rules run only under shapelint check --semantic.
  • Non-blocking by default. Findings are warnings unless a rule sets blocking: true.
  • Cached. Verdicts are cached by content hash, so an unchanged file never re-calls the model and results are reproducible.

Declaring a semantic rule

ts
{
  name: 'ui-primitive',
  files: 'components/ui/**/*.tsx',
  // string form → non-blocking
  semantic: 'Flag if this component contains business logic or data fetching.',
}

Blocking form:

ts
semantic: { prompt: 'No business logic in a primitive.', blocking: true }

Configuring the judge

The judge is provider-agnostic, Shapelint ships no AI SDK and never holds a key. You choose how the criterion reaches a model. There are two families, and the practical difference between them is which account pays:

ApproachFormWhat it consumes
Use your coding agent (Claude Code, Cursor, Gemini/Antigravity, a local model)judge: { command }The subscription/quota of the agent you already develop with: no API key, no extra signup. claude -p etc. draw from the same usage limits as your interactive sessions.
Use a provider API directlyjudge: async (input) => …A separate, per-token API bill (your own key), independent of any editor subscription. Best for CI.

Neither is "more correct", pick by whose wallet/quota you'd rather draw down. Local models (Ollama) are a third option that costs nothing but your own CPU.

How a command judge actually runs

When a semantic rule fires, Shapelint spawns the command as a fresh, headless subprocess, pipes the rendered prompt to its stdin, and reads a {"pass": …, "reason": …} object from its stdout. It does not open a chat window or inject into your current editor session, the prompts are invisible to your interactive agent. One process per uncached file; cached verdicts spawn nothing. See Caching.

Use your coding agent (shell command)

Any CLI that reads a prompt on stdin and writes JSON to stdout works. These reuse the login/subscription you already develop with, so they consume that account's usage limits.

ts
export default defineConfig({
  // `-p` = print/headless mode. Uses your Claude Code login, no API key.
  // ⚠️ Draws from the SAME usage limits as your interactive Claude Code.
  judge: { command: 'claude -p' },
  rules: [
    /* ... */
  ],
});
ts
export default defineConfig({
  // Cursor's CLI in non-interactive mode; uses your Cursor subscription.
  judge: { command: 'cursor-agent -p' },
  rules: [
    /* ... */
  ],
});
ts
export default defineConfig({
  // Google's Gemini CLI reads the piped prompt from stdin.
  judge: { command: 'gemini' },
  rules: [
    /* ... */
  ],
});
ts
export default defineConfig({
  // Runs entirely on your machine, no account, no network, no quota.
  judge: { command: 'ollama run llama3' },
  rules: [
    /* ... */
  ],
});
ts
export default defineConfig({
  // Simon Willison's `llm` tool, provider-agnostic wrapper.
  judge: { command: 'llm -m gpt-4o' },
  rules: [
    /* ... */
  ],
});

Shapelint already tells the model to reply with only a JSON object, and the parser extracts the first {…} block from stdout, so a little surrounding prose is tolerated. If a response can't be parsed, the file is treated as a pass (fail-open), which suits the non-blocking advisory tier.

Use a provider API directly (function)

A function receives { filePath, code, criterion } and returns { pass: boolean, reason?: string }. Bring your own SDK and key, this bills your API account, not any editor subscription, which makes it the right choice for CI.

ts
import Anthropic from '@anthropic-ai/sdk'; // your dependency, not shapelint's

const client = new Anthropic(); // reads ANTHROPIC_API_KEY

export default defineConfig({
  judge: async ({ filePath, code, criterion }) => {
    const res = await client.messages.create({
      model: 'claude-sonnet-5',
      max_tokens: 200,
      messages: [
        {
          role: 'user',
          content: `Criterion: ${criterion}\n\nFile ${filePath}:\n${code}\n\nReply JSON {"pass":bool,"reason":str}.`,
        },
      ],
    });
    const text = res.content[0].type === 'text' ? res.content[0].text : '{}';
    return JSON.parse(text.match(/\{[\s\S]*\}/)?.[0] ?? '{}');
  },
  rules: [
    /* ... */
  ],
});
ts
import OpenAI from 'openai'; // your dependency, not shapelint's

const client = new OpenAI(); // reads OPENAI_API_KEY

export default defineConfig({
  judge: async ({ filePath, code, criterion }) => {
    const res = await client.chat.completions.create({
      model: 'gpt-4o',
      response_format: { type: 'json_object' },
      messages: [
        {
          role: 'user',
          content: `Criterion: ${criterion}\n\nFile ${filePath}:\n${code}\n\nReply JSON {"pass":bool,"reason":str}.`,
        },
      ],
    });
    return JSON.parse(res.choices[0].message.content ?? '{}');
  },
  rules: [
    /* ... */
  ],
});
ts
import { GoogleGenerativeAI } from '@google/generative-ai'; // your dependency

const model = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!).getGenerativeModel({ model: 'gemini-1.5-pro' });

export default defineConfig({
  judge: async ({ filePath, code, criterion }) => {
    const res = await model.generateContent(`Criterion: ${criterion}\n\nFile ${filePath}:\n${code}\n\nReply JSON {"pass":bool,"reason":str}.`);
    const text = res.response.text();
    return JSON.parse(text.match(/\{[\s\S]*\}/)?.[0] ?? '{}');
  },
  rules: [
    /* ... */
  ],
});

Which should I pick?

  • Trying it out locally / occasional branch runs → a command judge like claude -p. Zero setup, but remember it eats your Claude Code quota.
  • CI, or you don't want to touch your editor quota → a function judge with a dedicated API key (separate bill), or a local ollama command (free).
  • Cost-sensitive and privacy-sensitiveollama run …, which never leaves your machine.

Whichever you choose, the content-hash cache means the expensive part is the first run, commit the cache and everyone after you reuses the verdicts.

Running it

bash
shapelint check --semantic

Without --semantic, semantic rules are skipped and the check stays fully deterministic. If --semantic is passed but no judge is configured, Shapelint warns and skips the AI pass.

Caching

Verdicts are stored in .shapelint/semantic-cache.json, keyed by a hash of the criterion plus the exact file contents. Commit it: an unchanged file reuses its verdict (no model call, stable result), and editing the file, or the criterion re-evaluates. Delete the file to force a full re-run.

End-to-end example

A self-contained config you can run today, the judge here is a local stub (no SDK, no network), so the whole loop is reproducible:

ts
// shapelint.config.ts
import { defineConfig } from 'shapelint';

export default defineConfig({
  root: 'src',
  // A real judge calls a model; this stub keeps the example deterministic.
  judge: async ({ code }) => ({
    pass: !/fetch\(|axios|prisma\./.test(code),
    reason: 'a UI primitive must not fetch data',
  }),
  rules: [
    {
      name: 'ui-primitive',
      files: 'components/ui/**/*.tsx',
      semantic: 'A UI primitive is presentational, no data fetching.',
    },
  ],
});
bash
# src/components/ui/Avatar.tsx contains `fetch('/api/user')`
shapelint check --semantic
# → ARCH_SEMANTIC (warn): a UI primitive must not fetch data
shapelint check --semantic   # second run: verdict served from
                           # .shapelint/semantic-cache.json, no judge call

Swap the stub for the function or { command } judge above to use a real model; everything else, the --semantic gate, the content-hash cache, the non-blocking default, is unchanged.

Diagnostic

Failures are reported as ARCH_SEMANTIC, warn severity by default, error when the rule sets blocking: true. The message carries the model's reason.

Why non-blocking by default. A model verdict is not perfectly reproducible, so making it a hard gate reintroduces the flakiness Shapelint exists to remove. Keep the deterministic tier as the gate; let the AI advise. Reach for blocking: true only for a criterion you trust and have cached.

Released under the MIT License.