Skip to content

Example config

A complete shapelint.config.ts that exercises every config option and rule property, shown in readable chunks below. It's a showcase, not a starting point: real configs are much smaller, so copy the pieces you need.

TIP

Every chunk below is imported from one type-checked shapelint.config.reference.ts, so the properties shown here are guaranteed valid.

Placeholders

Used in pattern and filename. A placeholder is consistent: bound once, it must be the same identifier everywhere it appears.

PlaceholderBinds an identifier that isExample
$NamePascalCaseButton, Card
$namecamelCasegetUser
$NAMECONSTANT_CASEGET, MAX
$_anything (unbound)any identifier

Config-level options

root, ignore, sameType, unmatched, sourceFiles, output, naming, and judge, plus the rules array. Rules are evaluated top to bottom; the first whose files glob matches a file claims it.

ts
export default defineConfig({
  /** Base directory for every rule glob. Default '.'. */
  root: 'src',

  /** Globs excluded from every rule (added to built-ins: node_modules, dist, *.d.ts). */
  ignore: ['**/*.d.ts', '**/generated/**', '**/*.gen.ts'],

  /**
   * Type spellings treated as equal (no type-checker on the fast path, so type
   * identity is textual). Built-in normalizer folds Array<T> ≡ T[]; custom
   * equivalence classes (like React.FC ≡ FC) are configured here.
   */
  sameType: [
    ['React.FC', 'FC', 'React.FunctionComponent'],
    ['ReactNode', 'React.ReactNode'],
  ],

  /**
   * What to do with a source file that NO rule claims.
   *   'ignore' (default) · 'warn' · 'error'
   * Set 'error' once your rules cover the repo: an agent then cannot create an
   * ungoverned file anywhere. This is the escape-hatch closer.
   */
  unmatched: 'ignore',

  /** The file universe scanned by `unmatched` and `belongsHere`. Default ts + tsx. */
  sourceFiles: ['**/*.ts', '**/*.tsx'],

  /** Default output format; the CLI `--format` flag overrides it. */
  output: 'human', // 'human' | 'agent' | 'json' | 'github'

  /**
   * Repo-wide file/folder naming defaults. A rule's `filename` / `foldername`
   * OVERRIDES these for the files/folders it governs. Conventions:
   *   'kebab-case' | 'camelCase' | 'PascalCase' | 'snake_case' | 'CONSTANT_CASE'
   */
  naming: {
    files: 'kebab-case', // the stem before the first dot (Button.test.tsx → "Button")
    folders: 'kebab-case', // every folder segment under root
    ignore: ['app/**'], // exempt framework folders (Next.js [id], (group))
  },

  /**
   * AI adapter for `semantic` rules (only under `check --semantic`). shapelint
   * bundles no SDK. Either a function `({ filePath, code, criterion }) => ({ pass, reason })`,
   * or a shell command reading stdin and writing that JSON:
   */
  judge: { command: 'claude -p' },

  /**
   * A whole-repo import boundary graph, declared once instead of repeating
   * `denyImports` on every rule. Lower `order` layers may not import higher
   * ones; `shared` layers are importable by anyone. Emits
   * `ARCH_LAYER_VIOLATION` naming the offending edge (e.g. `schema → feature`).
   */
  layers: {
    order: ['schema', 'utils', 'core', 'feature'],
    shared: ['common'],
    map: {
      schema: 'database/schemas/**',
      utils: 'utils/**',
      core: 'core/**',
      feature: 'features/**',
      common: 'common/**',
    },
    // Optional: forbid one feature reaching another feature's internals.
    within: { feature: { noDeepImports: ['*/controllers/**', '*/dto/**'] } },
  },

  // Rules are evaluated top to bottom; EVERY rule whose `files` matches applies,
  // short-circuiting on the first that reports an error.
  rules: [uiComponent, blockComponent, componentBodyOrder, service, formFields, routeHandler, hookNaming, controller, gateway, serviceDi, modelBarrel, featureShape, dataModel, legacyWidgets],
});

Rules

Each rule below is a defineRule({ … }). See the linked concept pages for the full behavior of each property.

1. The kitchen-sink rule

Nearly every property at once, pattern, filename/foldername, denyImports, disallowElements/disallowCalls, allowExtraExports, requires, belongsHere, hint, severity, and semantic.

ts
// A rule using nearly every property.
const uiComponent = defineRule({
  /** Rule id, appears in every diagnostic and in `// shapelint-disable <name>`. */
  name: 'ui-component',

  /** One line on intent, shown by `explain` and in diagnostics. */
  description: 'Primitive, presentational building blocks.',

  /** Glob(s) relative to `root`. Required. */
  files: 'components/ui/**/*.tsx',

  /** Skip these within this rule. */
  exclude: ['**/*.test.tsx', '**/*.stories.tsx'],

  /**
   * The code template the file must match, "the template is a lower bound":
   * bodies, extra private declarations, and interface members are NOT
   * constrained; the export surface IS exact.
   */
  pattern: `
    interface IProps {}

    const $Name: React.FC<IProps> = () => {}

    export default $Name
  `,

  /**
   * Filename convention. Forms:
   *   '$Name.tsx'   → binds $Name from the filename (Button.tsx → $Name=Button)
   *   'kebab-case' | 'PascalCase' | 'camelCase'  → named conventions
   *   'use-*'       → glob
   * Overrides config `naming.files`.
   */
  filename: '$Name.tsx',

  /** Convention for the parent folder. Overrides config `naming.folders`. */
  foldername: 'PascalCase', // components/ui/Button/Button.tsx → "Button" folder

  /** Import specifiers this file may NOT use (globs over resolved paths). */
  denyImports: ['services/**', 'store/**', 'app/**'],

  /** Ban JSX elements/components; require a replacement (see the usage rule for `within`). */
  disallowElements: [{ name: 'input', useInstead: 'the Input primitive', except: ['components/ui/Input.tsx'] }],

  /** Ban calls; `*` is a wildcard segment. */
  disallowCalls: [
    { name: 'fetch', useInstead: 'a service in @/services, via props' },
    { name: 'axios.*', useInstead: 'a service in @/services' },
  ],

  /**
   * Closed export surface. Default (omitted) = OPEN.
   *   false           → only what the pattern declares may be exported
   *   ['use*', 'foo'] → closed, except names matching these globs
   *   true            → open (explicit)
   */
  allowExtraExports: false,

  /**
   * Sibling files that must exist next to each match. `{stem}` = the name before
   * the FIRST dot (orders.module.ts → orders; Button.tsx → Button), the same
   * stem `naming.files`/`filename` use.
   */
  requires: ['index.ts', '{stem}.stories.tsx'],

  /**
   * Inverse check: a file ANYWHERE that matches this signature but is not under
   * `files` is flagged as misplaced (and `fix` can move it here).
   */
  belongsHere: {
    exportsType: 'React.FC', // a top-level export annotated with this type…
    importsNone: ['services/**', 'store/**'], // …and importing none of these
  },

  /** Fix guidance shown at the bottom of this rule's diagnostics. */
  hint: 'Primitives are presentational, lift data access into a block component.',

  /** 'error' (default) | 'warn' | 'off'. */
  severity: 'error',

  /**
   * Natural-language criterion for the AI tier (only under `check --semantic`).
   * String form is non-blocking (warning); object form can block:
   *   semantic: { prompt: '…', blocking: true }
   */
  semantic: 'Flag if this component contains business logic or data fetching.',
});

2. Multiple acceptable shapes: a pattern list

A plain component or a forwardRef one. Give pattern an array of templates; the file passes if it matches any one, and on failure the diagnostic diffs against the closest variant. See pattern matching.

ts
// A `pattern` list, several acceptable shapes for one rule.
const blockComponent = defineRule({
  name: 'block-component',
  description: 'Composite components. Plain OR forwardRef.',
  files: 'components/block/**/*.tsx',
  // A file passes if it matches ANY variant; on failure the diagnostic diffs
  // against the closest one. Pass `pattern` an array instead of a string.
  pattern: [
    `
      interface IProps {}
      const $Name: React.FC<IProps> = () => {}
      export default $Name
    `,
    `
      interface IProps {}
      const $Name = React.forwardRef<HTMLElement, IProps>(() => {})
      export default $Name
    `,
  ],
  filename: '$Name.tsx',
  // Only INTERNAL imports are governed by allowImports; bare packages are not.
  allowImports: ['components/ui/**', 'hooks/**', 'services/**', 'lib/**'],
});

3. A non-React rule: services

kebab-case filenames, a $name (camelCase) placeholder, and an open export surface. See import & placement.

ts
// A non-React rule: services (kebab filenames, multiple exports, $name).
const service = defineRule({
  name: 'service',
  description: 'Data access; the only layer that talks to the network.',
  files: 'services/**/*.ts',
  filename: 'kebab-case',
  pattern: `export const $name = async () => {}`,
  allowExtraExports: true, // services legitimately export several functions
  denyImports: ['components/**', 'app/**'],
});

4. Context-sensitive usage:within + except

Inside a <Form>, require InputField. within is lexical (same file). See usage rules.

ts
// Context-sensitive usage rule (`within` is lexical + `except`).
const formFields = defineRule({
  name: 'form-fields',
  description: 'Inside a <Form>, use InputField, not a bare Input or <input>.',
  files: 'components/**/*.tsx',
  disallowElements: [
    // `within` is LEXICAL (same file): flagged only inside <Form>…</Form>.
    { name: 'Input', useInstead: 'InputField', within: 'Form' },
    { name: 'input', useInstead: 'InputField', within: 'Form' },
  ],
});

5. CONSTANT_CASE exports: route handlers

$NAME binds a CONSTANT_CASE identifier (GET, POST), with an open export surface for the several handlers in one file.

ts
// Next.js route handlers (CONSTANT_CASE exports).
const routeHandler = defineRule({
  name: 'route-handler',
  description: 'Thin route handlers, delegate to services.',
  files: 'app/**/route.ts',
  pattern: `export const $NAME = async (request: Request) => {}`,
  allowExtraExports: true, // GET, POST, PATCH… in one file
  denyImports: ['components/**'],
});

6. Backend controllers with correlated naming

A NestJS or OOP controller whose class name correlates with the filename (get-users.controller.tsclass GetUsersController). See member contracts.

ts
// The filename and the class name must use the same word (in their own casing).
const controller = defineRule({
  name: 'controller',
  description: 'One controller per resource; name derived from the filename.',
  files: 'controllers/**/*.controller.ts',
  // `${Name}` is a brace hole; `Controller` is a required literal suffix.
  pattern: 'export class ${Name}Controller {}',
  // `$name` (filename) and `$Name` (class) are ONE word across cases,
  // automatically: get-users.controller.ts ⇒ class GetUsersController.
  filename: '$name.controller.ts',
});

7. Gateway & member-level decorator contracts

Per-method decorator requirements (methods.where, methods.require, and methods.oneOf). See member contracts.

ts
// Applies the pattern to the decorated class, plus per-method rules.
const gateway = defineRule({
  name: 'gateway',
  description: 'WebSocket gateways; every message handler is authed + documented.',
  files: 'gateways/**/*.ts',
  // The pattern's class carries a decorator, so `target` defaults to 'decorated':
  // local helper classes above the real one are ignored (no `exclude` needed).
  pattern: '@WebSocketGateway($_) export class ${Name}Gateway {}',
  // Member-level shape: any @SubscribeMessage handler must be @UseGuards'd and
  // must be exactly one of @Protected/@Public.
  methods: {
    where: '@SubscribeMessage',
    require: ['@UseGuards'],
    oneOf: ['@Protected', '@Public'],
  },
});

8. Constructor dependency injection contract

Require class constructors to inject specific dependencies (e.g. Logger) and restrict injected parameter types to an allow-list. See member contracts.

ts
// What each service's constructor must take (its injected dependencies).
const serviceDi = defineRule({
  name: 'service-inject',
  description: 'Every service injects a scoped logger and only allowed collaborators.',
  files: 'services/**/*.service.ts',
  inject: {
    require: ['private readonly $_: Logger'],
    allowParams: ['*Service', '*Repository', 'Logger'],
  },
});

9. Structural cardinality & barrel completeness

Ensure folder cardinality (contains: { '*.controller.ts': 'exactly-one' }) and ensure index barrel files re-export all sibling models. See structure.

ts
// An index that must re-export every sibling + how many of each file a folder needs.
const modelBarrel = defineRule({
  name: 'model-barrel',
  description: 'The models barrel must re-export every sibling model.',
  files: 'models/index.ts',
  barrel: { mustReexport: './*.model.ts' },
});

const featureShape = defineRule({
  name: 'feature-shape',
  description: 'Each feature folder has exactly one controller and module.',
  files: 'features/*',
  contains: { '*.controller.ts': 'exactly-one', '*.module.ts': 'exactly-one' },
});

10. Plural-aware data models (Drizzle / ORM)

Correlate plural table constants (users) with singular row types (User) using matchNames: 'plural-aware'.

ts
// Match singular/plural names: plural table const, singular row type, plural relations.
const dataModel = defineRule({
  name: 'data-model',
  description: 'Drizzle-style models correlate the plural table with its singular row.',
  files: 'models/*.model.ts',
  matchNames: 'plural-aware',
  pattern: ['export const ${name} = $_', 'export type ${Name:singular} = $_', 'export const ${name}Relations = $_'].join('\n'),
});

11. Component body ordering and spacing

Enforce statement ordering, plain variable placement ('variable'), and blank-line spacing (interGroupSpacing: 'always', intraGroupSpacing: 'never') inside component bodies. See body order.

ts
// Enforce statement order and blank-line spacing inside component bodies.
const componentBodyOrder = defineRule({
  name: 'component-body-order',
  description: 'Statement ordering, plain variable placement, and group padding.',
  files: 'components/**/*.tsx',
  bodyOrder: {
    interGroupSpacing: 'always',
    intraGroupSpacing: 'never',
    groups: [
      { name: 'custom hooks', match: ['use*'] },
      { name: 'useRef', match: ['useRef'] },
      { name: 'useState', match: ['useState', 'useReducer'] },
      { name: 'derived state', match: ['variable'] },
      { name: 'functions', match: ['function', 'useCallback'] },
      { name: 'useEffect', match: ['useEffect', 'useLayoutEffect'] },
      { name: 'return', match: ['return'] },
    ],
  },
});

12. A filename-only rule

No pattern, a rule just needs one enforcing option. See filename & naming.

ts
// A filename-only rule (no pattern).
const hookNaming = defineRule({
  name: 'hook-naming',
  description: 'Hooks must be use-*.ts.',
  files: 'hooks/**/*.ts',
  filename: 'use-*',
  // A rule needs at least ONE enforcing option (pattern, filename, an
  // import/usage rule, requires, or belongsHere). This one uses filename only.
});

13. A disabled rule

severity: 'off', kept in the config for documentation or a staged rollout.

ts
// A rule turned off (kept for documentation / staged rollout).
const legacyWidgets = defineRule({
  name: 'legacy-widgets',
  description: 'Not enforced yet, flip to error once migrated.',
  files: 'widgets/**/*.tsx',
  pattern: `const $Name: React.FC = () => {}\nexport default $Name`,
  severity: 'off',
});

Released under the MIT License.