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
{
name: 'ui-primitive',
files: 'components/ui/**/*.tsx',
// string form → non-blocking
semantic: 'Flag if this component contains business logic or data fetching.',
}Blocking form:
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:
| Approach | Form | What 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 directly | judge: 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.
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: [
/* ... */
],
});export default defineConfig({
// Cursor's CLI in non-interactive mode; uses your Cursor subscription.
judge: { command: 'cursor-agent -p' },
rules: [
/* ... */
],
});export default defineConfig({
// Google's Gemini CLI reads the piped prompt from stdin.
judge: { command: 'gemini' },
rules: [
/* ... */
],
});export default defineConfig({
// Runs entirely on your machine, no account, no network, no quota.
judge: { command: 'ollama run llama3' },
rules: [
/* ... */
],
});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.
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: [
/* ... */
],
});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: [
/* ... */
],
});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
ollamacommand (free). - Cost-sensitive and privacy-sensitive →
ollama 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
shapelint check --semanticWithout --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:
// 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.',
},
],
});# 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 callSwap 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: trueonly for a criterion you trust and have cached.