# shapelint > shapelint is a deterministic, 100% framework-agnostic linter that enforces your code's > **architecture**: shape, naming, placement, imports, and API/member contracts across > frontend (React, Vue, Svelte, Angular), backend (NestJS, Express, Node.js), and Clean > Architecture layers, so AI-generated code conforms to your conventions instead of drifting. > It complements ESLint and `tsc` (never re-reporting what they catch) and works across > TypeScript and JavaScript. This file is the complete spec for an AI agent to configure and use it. ## What it does You declare conventions by **example**: a code template with `$Name` holes, not AST selectors, plus import/usage/naming/placement rules. Any file that doesn't conform fails `shapelint check` with a precise, prescriptive error an agent can fix in one pass. The rules live in `shapelint.config.ts`, not in the model's context. ## Install & run ```bash npm add -D shapelint # or pnpm/yarn npx shapelint init # writes a starter shapelint.config.ts (detects src/) npx shapelint check # the gate: exit 1 on error, 2 on config problem ``` The config lives at the project root as `shapelint.config.ts` (loaded via jiti; a TS config needs no build step). A plain object works without importing helpers. ## Config file ```ts import { defineConfig } from 'shapelint' export default defineConfig({ root: 'src', rules: [ /* ... */ ] }) ``` ### Config-level options - `root?: string`, base dir for all rule globs. Default `'.'`. - `ignore?: string[]`, globs excluded from every rule. Built-in ignores: `node_modules`, `dist`, `*.d.ts`, and `.shapelint/**` (shapelint's own dir). - `sameType?: string[][]`, classes of equal type spellings, e.g. `[['React.FC','FC']]`. Built-in normalizer folds `Array ≡ T[]`; custom equivalences are configured here. - `unmatched?: 'ignore' | 'warn' | 'error'`, what to do with a source file no rule claims. Default `'ignore'`. Set `'error'` once rules cover the repo so an agent cannot create an ungoverned file. - `sourceFiles?: string[]`, the universe scanned by `unmatched`/`belongsHere`. Default `['**/*.ts','**/*.tsx']`. - `output?: 'human' | 'agent' | 'json' | 'github'`, default output format. - `naming?: { files?, folders?, ignore? }`, repo-wide naming defaults (see below). - `judge?`, AI adapter for `semantic` rules (function or `{ command }`). - `layers?`, a whole-repo import boundary graph (see Layer graph below). - `rules: Rule[]`, required. Evaluated top-to-bottom; EVERY rule whose `files` glob matches a file applies to it, in array order, until one reports an error (short-circuit). Independent checks on ONE rule (e.g. `pattern` + `memberOrder` + `methods`) all run and stack. ### Rule options - `name: string`, required; appears in diagnostics and `// shapelint-disable `. - `description?: string`, one line, shown by `explain` and in diagnostics. - `files: string | string[]`, required; glob(s) relative to `root`. - `exclude?: string[]`, globs skipped within this rule. - `pattern?: string | string[]`, code template(s) the file must match (see Placeholders). A list gives several acceptable shapes; passes if it matches any one. - `target?: 'decorated' | 'exported' | 'first'`, which declaration the `pattern` targets when several of its kind exist. Defaults to `'decorated'` when the pattern's class has a decorator, else `'first'`. - `methods?: MethodSpec | MethodSpec[]`, per-member contract. For every member the `where` selector matches: - `require: string[]`, decorators that must ALL co-occur (`ARCH_MEMBER_PATTERN`). - `oneOf: string[]`, EXACTLY ONE of the group must be present; `anyOf: string[]`, AT LEAST ONE (`ARCH_MEMBER_DECORATOR_GROUP`). Use `oneOf: ['@Protected','@Public']` to force every route to be explicitly guarded xor public. `where` has two dialects: a DECORATOR selector (`@Name`, `@(Get|Post|Put)`, `@Api*`) or a SIGNATURE selector, modifier words + a name glob (`'public *'`, `'private get*'`, `'static handle'`; modifiers: public/private/protected/static/ readonly/async/get/set). Omit `where` to select every method. - `memberOrder?: string[]`, order of class-member GROUPS, top to bottom, e.g. `['field','constructor','public-method','private-method']`. Each entry is a kind (`field`, `constructor`, `method`, `getter`, `setter`) optionally prefixed with `public|protected|private|static|decorated` (`public-method`, `static-field`, `decorated-method`); a bare kind matches any visibility. A lower bound like `bodyOrder`: a member is classified into the FIRST listed group it matches, members matching no listed group are ignored, and only the relative order of listed groups is enforced (no gap/spacing rules). A private method above a public one is `ARCH_MEMBER_ORDER`, pointing at the out-of-order member. - `inject?: { require?: string[]; allowParams?: string[] }`, constructor / DI contract for each top-level class. `require` lists parameter templates that must be present (`'private readonly $_: Logger'`, named modifiers must be present too); `allowParams` restricts injected TYPES to a glob allow-list (`['*Service','*Repository','Logger']`). Failures are `ARCH_CONSTRUCTOR_CONTRACT`. (Named `inject`, not `constructor`, to avoid the `Object.prototype.constructor` collision.) - `optionalNames?: string[]`, templates for exports that MAY appear; each that does must follow the correlated naming (`['${NAME}_QUERY','${NAME}_BODY']`, a `usersBody` misnamed in camelCase, or one correlated to the wrong file word, fails `ARCH_MEMBER_CORRELATION`). - `pairWith?: { when: string; require: string } | […]`, pairing rule: for every declaration matching `when` (e.g. `'const ${x}Schema'`) a declaration matching `require` (`'class ${X}'`, correlated hole) must exist. `ARCH_MEMBER_CORRELATION`. - `decoratorArgs?: { name: string; arg?: number; convention?: NameConvention; correlateToFilename?: boolean }[]`, validate a decorator's positional string argument against a convention and/or the filename word. `@Route('UserStuff')` on `users.controller.ts` fails; `@Route('users')` passes. `ARCH_DECORATOR_ARG`. - `matchNames?: 'exact' | 'plural-aware'` (+ `plurals?: Record`), correlation mode. `'plural-aware'` relates the singular and plural forms of one word, so a plural file stem correlates with a `${Name:singular}` hole. A plural where a singular is required is `ARCH_NAME_PLURALITY`. `plurals` adds irregular pairs (`{ people: 'person' }`, key plural → value singular). - `barrel?: { mustReexport: string }`, index/barrel completeness: this file must re-export EVERY sibling matching the glob (relative to the index's own dir, e.g. `'./*.model.ts'`). A forgotten sibling is `ARCH_BARREL_INCOMPLETE`. - `contains?: Record`, cardinality per folder. `files` must match FOLDERS (`'features/*'`); each entry constrains how many matching files each folder holds (`{ '*.controller.ts': 'exactly-one' }`). `ARCH_CARDINALITY`. - `filename?: string`, `'$Name.tsx'` (binds `$Name` from the name), or a convention (`'kebab-case'`, `'PascalCase'`, `'camelCase'`, `'snake_case'`, `'CONSTANT_CASE'`), or a glob (`'use-*'`). Overrides config `naming.files`. A convention judges the stem before the FIRST dot, the same definition `naming.files` uses, so `filename: 'kebab-case'` passes `user-cache.module.ts`. - `foldername?: NameConvention`, convention for the parent folder of matched files. Overrides config `naming.folders`. - `denyImports?: string[]` / `allowImports?: string[]`, import path rules. Specifiers resolve: relative → path relative to `root`; `@/x` → `x`; bare `react` stays external. `denyImports` matches resolved path OR raw specifier. `allowImports` governs only INTERNAL imports (bare packages are out of scope). - `disallowElements?: UsageBan[]`, ban JSX elements/components, require a replacement. - `disallowCalls?: UsageBan[]`, ban calls (`fetch`, `axios.*`, `use*Query`). - `bodyOrder?: { in?: string; groups: { name: string; match: string[] }[]; interGroupSpacing?: 'always' | 'never' | 'optional'; intraGroupSpacing?: 'never' | 'always' | 'optional' }`, enforce statement order and blank-line spacing inside component/function bodies. Groups match literal calls, wildcards (`'use*'`), `'function'`, `'variable'` / `'const'`, or `'return'`. - `allowExtraExports?: boolean | string[]`, export surface. Default OPEN. `false` = only what the pattern declares may be exported; `['use*']` = closed except matching names. - `requires?: string[]`, sibling files that must exist next to each match. `{stem}` expands to the name before the FIRST dot, the same stem `naming.files` and `filename` conventions use (`orders.module.ts` → `orders`, `Button.tsx` → `Button`). So `['{stem}.controller.ts','{stem}.service.ts']` next to `orders.module.ts` requires `orders.controller.ts`/`orders.service.ts`, and `'{stem}.stories.tsx'` next to `Button.tsx` requires `Button.stories.tsx`. A missing companion is `ARCH_MISSING_SIBLING`. - `belongsHere?: { exportsType?: string; importsNone?: string[] }`, inverse check: a file ANYWHERE matching this signature but not under `files` is flagged as misplaced (and `fix` can move it). - `semantic?: string | { prompt: string; blocking?: boolean }`, natural-language criterion for the AI tier; runs only under `check --semantic`. Non-blocking (warning) unless `blocking: true`. - `hint?: string`, fix guidance shown in this rule's diagnostics. - `severity?: 'error' | 'warn' | 'off'`, default `'error'`. `UsageBan = { name: string; useInstead: string; within?: string; except?: string[] }` - `name`: JSX tag / component / call callee; `*` is a wildcard segment. - `within`: only inside this LEXICAL JSX ancestor (same file), e.g. `'Form'`. - `except`: file globs exempt (e.g. the component's own file). `NameConvention = 'kebab-case' | 'camelCase' | 'PascalCase' | 'snake_case' | 'CONSTANT_CASE'` ### Placeholders (in `pattern` and `filename`) - `$Name` → binds a PascalCase identifier (Button, UserCard) - `$name` → binds a camelCase identifier (getUser, total) - `$NAME` → binds a CONSTANT_CASE identifier (GET, MAX_SIZE) - `$_` → matches any identifier (or, in a type position, any type), unbound A placeholder is **consistent**: bound once, it must be the same identifier everywhere it appears, including in `filename`. **Affixes (brace holes).** A bare `$Name` binds a WHOLE identifier. To require a literal prefix/suffix, brace-delimit the hole: `${Name}Controller` binds the PascalCase core and requires the suffix `Controller` (`UsersController` ✓, `UsersThing` ✗ → `ARCH_NAME_AFFIX_MISMATCH`). Also `App${Name}Service`, `use${Name}`, `${NAME}_TOKEN`. A brace hole with no affix (`export type ${Name} = $_`) just equals `$Name`. **Correlation (automatic).** `$name`/`$Name`/`$NAME` and the `filename` hole are ONE logical word: shapelint binds it once, transforms between cases, and requires every occurrence to spell the same word. So `filename: '$name.controller.ts'` + `pattern: 'export class ${Name}Controller {}'` requires `get-users.controller.ts` ⇒ `GetUsersController` (a `FetchUsersController` fails with `ARCH_NAME_CORRELATION_MISMATCH`). This also ties several correlated declarations in one file (a `type` + `${Name}Schema` const + `${NAME}_REQUEST` class). Use different letters (`$Name` vs `$Other`) for genuinely independent holes. **Plural-aware correlation.** With `matchNames: 'plural-aware'`, the singular and plural spellings of one word matchNames: a plural `${name}` (from a plural file stem) matches a `${Name:singular}` hole. The `:singular` / `:plural` modifier on a brace hole pins the word's grammatical number (`export type ${Name:singular}` on `user-accounts.model.ts` requires `UserAccount`; a plural `UserAccounts` fails `ARCH_NAME_PLURALITY`). Irregular pairs go in the rule's `plurals` map (`{ people: 'person' }`). ### The governing principle **"The template is a lower bound."** Anything the template states must hold; anything it does not state is unconstrained. Function bodies, interface members, declaration order, and extra PRIVATE declarations are NOT constrained. The export FORM of declarations the template names IS exact; extra EXPORTS are allowed unless `allowExtraExports` closes the surface. ## Repo-wide naming ```ts naming: { files: 'kebab-case', // stem before the first dot (Button.test.tsx → "Button") folders: 'kebab-case', // stem before the first dot of each folder segment ignore: ['app/**'], // exempt framework folders (Next.js [id], (group)) } ``` Precedence: a claiming rule's `filename`/`foldername` overrides these for what it governs; unmatched files fall back to `naming`. Folder naming uses the same first-dot stem, so a dotted convention folder like `orders.dto` validates its stem `orders` (`OrdersDto`/`chat_rooms` still fail `kebab-case`). ## Layer graph Declare import boundaries once, instead of repeating `denyImports` on every rule: ```ts layers: { order: ['schema', 'utils', 'core', 'feature'], // lower may NOT import higher shared: ['common'], // importable by any layer map: { schema: 'database/schemas/**', utils: 'utils/**', core: 'core/**', feature: 'features/**', common: 'common/**', }, within: { feature: { noDeepImports: ['*/controllers/**', '*/dto/**'] } }, // severity?: 'error' | 'warn' (default 'error') } ``` A lower-index layer importing a higher-index one is `ARCH_LAYER_VIOLATION` (naming the edge, e.g. `schema → feature`). `shared` layers are importable by anyone. `within..noDeepImports` forbids one unit reaching another sibling unit's internals in the same layer. Per-rule `denyImports`/`allowImports` still augment this. ## Rules in their own file Put each rule under `.shapelint/rules/.ts` (`export default defineRule({...})`) and import it. Files are explicitly imported, never auto-discovered. `.shapelint/` is ignored by shapelint. Mix imported and inline rules freely. `defineConfig` / `defineRule` are identity helpers, plain objects also work. ## Semantic (AI) tier Provider-agnostic; shapelint bundles no SDK. `judge` is a function `({ filePath, code, criterion }) => ({ pass: boolean, reason?: string })`, or a `{ command: 'claude -p' }` that reads a prompt on stdin and writes that JSON. Runs only under `check --semantic`. Verdicts cached by content hash in `.shapelint/semantic-cache.json`. Non-blocking by default. ## CLI - `shapelint check [paths]`, flags: `--format human|agent|json|github`, `--semantic`, `--cache`. - `shapelint fix`, mechanical autofix (rename symbol; change export form; rename file to a convention; move a misplaced `belongsHere` file, all rewrite importers). `--write` to apply (dry-run by default), `--force` to allow a dirty git tree. Verify-and-rollback: writes nothing unless errors drop and no clean file regresses. - `shapelint new `, scaffold a conforming file from a rule's pattern. - `shapelint baseline [--prune]`, grandfather current violations into `.shapelint/baseline.json`; `check` then reports them as "N grandfathered". - `shapelint explain `, which rule claims a file and why. - `shapelint init`, write a starter config (detects `src/`). Exit codes: `0` clean (or warnings only), `1` error-severity findings, `2` operational failure (no/invalid config). ## Diagnostic codes `ARCH_MISSING_DECL`, `ARCH_NAME_MISMATCH`, `ARCH_KIND_MISMATCH`, `ARCH_EXPORT_FORM`, `ARCH_TYPE_MISMATCH`, `ARCH_NAME_AFFIX_MISMATCH`, `ARCH_NAME_CORRELATION_MISMATCH`, `ARCH_MISSING_DECORATOR`, `ARCH_MEMBER_PATTERN`, `ARCH_CONSTRUCTOR_CONTRACT`, `ARCH_MEMBER_CORRELATION`, `ARCH_MEMBER_DECORATOR_GROUP`, `ARCH_MEMBER_ORDER`, `ARCH_DECORATOR_ARG`, `ARCH_NAME_PLURALITY`, `ARCH_BARREL_INCOMPLETE`, `ARCH_CARDINALITY`, `ARCH_LAYER_VIOLATION`, `ARCH_FILENAME_MISMATCH`, `ARCH_FOLDERNAME_MISMATCH`, `ARCH_DENIED_IMPORT`, `ARCH_UNMATCHED_FILE`, `ARCH_DISALLOWED_ELEMENT`, `ARCH_DISALLOWED_CALL`, `ARCH_EXTRA_EXPORT`, `ARCH_BODY_ORDER`, `ARCH_BODY_INTRA_SPACING`, `ARCH_BODY_INTER_SPACING`, `ARCH_MISSING_SIBLING`, `ARCH_MISPLACED_FILE`, `ARCH_SUPPRESS_NO_REASON`, `ARCH_SEMANTIC`. ## Suppressions `// shapelint-disable-next-line -- ` (also `-line`, `-file`). A reason is mandatory, a reasonless suppression is itself an error. ## Adoption workflow 1. `unmatched: 'ignore'`; write rules that mirror the repo's real conventions. 2. `npx shapelint check`; fix or loosen until green. 3. `npx shapelint baseline` to grandfather legacy violations. 4. Flip `unmatched: 'error'` (and add `belongsHere`) to close the escape hatch. 5. Wire into CI (`shapelint check --cache --format github`) and tell your agent to run `npx shapelint check --format agent` after edits and fix what it reports. ## Full example config See the annotated reference: https://github.com/shapelint/core/blob/main/docs/snippets/shapelint.config.reference.ts ## Key facts for an agent - Mirror the repo's EXISTING conventions in `pattern`, don't impose new ones. - Rules stack: all matching rules apply in array order, short-circuiting on the first error. Split orthogonal conventions (shape vs. body order) into separate rules over the same files. - `@/` alias resolves to the `root` prefix for import rules. - Don't force framework files (Next.js `page.tsx`/`layout.tsx`/`route.ts`) into a component pattern, give them their own rules or `ignore` them.