Pattern matching
A pattern is the code you want the file to look like, written as a normal TypeScript snippet with $-prefixed placeholders. A file matches if its top-level declaration surface conforms to the template.
pattern: `
interface IProps {}
const $Name: React.FC<IProps> = () => {}
export default $Name
`;pattern: `
@Controller('$name')
export class ${Name}Controller {}
`;pattern: `
export const $name = async (params: $NameParams): Promise<$NameResponse> => {}
`;pattern: `
export interface ${Name}Model {
id: string;
}
export type ${Name}CreateInput = Omit<${Name}Model, 'id'>;
`;The governing principle
The template is a lower bound. Anything the template states must hold. Anything the template does not state is unconstrained.
That single rule explains everything below.
What is compared
| Aspect | Compared? |
|---|---|
Declaration kind (interface vs type vs const vs function vs class) | ✅ strictly |
| Declaration name | ✅ literal, or bound via a placeholder |
| Export form (default vs named) | ✅ exact, see below |
| Type annotation the template states | ✅ compared textually |
const / let / var keyword | ✅ |
Variable initializer kind the template states (e.g. = () => {} → an arrow) | ✅ |
What is ignored: by design
| Aspect | Ignored? |
|---|---|
| Function / method bodies | ✅ never looked at by a pattern, see note below |
| Declaration order | ✅ interface-first or component-first both pass |
| Interface / class members | ✅ interface IProps {} means "exists", not "empty" |
| Extra declarations in the file | ✅ private helpers, sub-components, constants are fine |
| Type annotations the template omits | ✅ unconstrained |
Parameters the template omits (() => {}) | ✅ "don't care" |
| Comments, blank lines, semicolons, quotes | ✅ |
import statements | ✅ not part of a pattern |
So this file passes the pattern above, despite the extra helper, a populated interface, reordered declarations, and a full body:
Bodies are opaque to a
pattern, but not off-limits to Shapelint. Two rule options reach inside them: usage rules ban specific elements/calls, andbodyOrderfixes the order of statements (e.g.useRef→useState→useEffect→return).
const styles = 'px-2'; // extra private declaration, fine
const Button: React.FC<IProps> = ({ label }) => {
const [open, setOpen] = useState(false); // body, never inspected
return <button>{label}</button>;
};
interface IProps {
label: string;
} // out of order, has members, fine
export default Button;Export form is exact
The export surface is the one thing the template pins down precisely, because it is the architectural boundary that matters, it's what other modules can reach. The recognized forms:
| Form | Example |
|---|---|
none | const X = ... (not exported) |
named | export const X = ... |
default-inline | export default function X() {} |
default-assignment | const X = ... then export default X |
export default X is folded back onto X's declaration, so const X = ...; export default X and the template describe the same thing.
Placeholders
A placeholder is any identifier starting with $. It binds to whatever identifier appears in that position, and the casing of the placeholder name is the casing constraint on what it binds:
| Placeholder | Binds an identifier that is | Example match |
|---|---|---|
$Name | PascalCase | Button, Card |
$name | camelCase | getUser, total |
$NAME | CONSTANT_CASE | GET, MAX_SIZE |
$_ | anything (anonymous) | any identifier |
A placeholder is consistent: $Name bound once must be the same identifier everywhere it appears, in the template and in the filename. So filename: '$Name.tsx' on Button.tsx binds $Name = Button, and the pattern then requires the component to be named Button.
Placeholders are valid in identifier positions only, declaration names, type references (React.FC<$Props>), export clauses, and filename specs.
Affixes: brace-delimited holes with literal prefixes/suffixes
A bare $Name binds a whole identifier. To require a fixed prefix or suffix around the hole, brace-delimit it as ${Name} so Shapelint knows where the hole ends and the literal identifier text begins:
| Pattern name | Matches | Binds | Rejects |
|---|---|---|---|
${Name}Controller | UsersController | $Name=Users | UsersThing |
App${Name}Service | AppCacheService | $Name=Cache | CacheService |
use${Name} | useCache | $Name=Cache | |
${NAME}_TOKEN | AUTH_TOKEN | $NAME=AUTH | AUTH_KEY |
The hole's case sigil still applies to the stripped core (${Name} → the core is PascalCase). A name that doesn't carry the required literal affix is reported as ARCH_NAME_AFFIX_MISMATCH. Brace holes with no affix (export type ${Name} = $_) are just an alternative spelling of $Name.
Correlated placeholders
One logical name often appears in different cases across a file and its name, a kebab-case filename, a PascalCase export, a CONSTANT_CASE export. $name, $Name and $NAME (plus the filename hole) are one logical word, automatically: Shapelint binds it once, transforms between cases, and requires every occurrence to spell the same word. Use different letters ($Name vs $Other) when you want genuinely independent holes.
{
files: '*/*.controller.ts',
filename: '$name.controller.ts', // binds the word "get-users"
pattern: 'export class ${Name}Controller {}', // ⇒ must be "GetUsersController"
}get-users.ts exporting class GetUsersController passes; class FetchUsersController fails with ARCH_NAME_CORRELATION_MISMATCH (and an autofix rename to GetUsersController). This also correlates several declarations within one file: a type, a schema const, and request classes that share the word:
pattern: `
export type ${Name} = $_
export const ${Name}Schema = $_
export class ${NAME}_REQUEST {}
export class ${NAME}_RESPONSE {}
`;Anchoring: which declaration the pattern targets
By default the pattern binds to the first top-level declaration of its kind. When a file declares local helper classes above the "real" one, that's wrong:
class SUBSCRIBE_BODY extends makeBody(schema) {} // local helper
class UNSUBSCRIBE_BODY extends makeBody(schema) {} // local helper
@WebSocketGateway({ namespace: 'chat' })
export class ChatGateway {} // the class the rule meansSet target to target the declaration the pattern structurally describes:
'decorated', the class carrying the decorator the pattern names. This is the default whenever the pattern's class has a decorator, so the rule@WebSocketGateway($_) export class ${Name}Gateway {}bindsChatGatewayabove with noexclude.'exported', the single exported declaration, ignoring non-exported helpers.'first', the legacy positional default.
A class that the pattern decorates must actually carry those decorators, else ARCH_MISSING_DECORATOR.
Member patterns (methods)
The strongest conventions are per-member. methods requires a shape for every member a decorator selector matches:
{
files: '*/*.controller.ts',
methods: {
where: '@(Get|Post|Put|Delete|Patch)', // decorator selector on the member
require: ['@Auth', '@ApiResponse'], // decorators that must co-occur
},
}A @Get() handler missing @ApiResponse is flagged ARCH_MEMBER_PATTERN, the defect being an unguarded or undocumented endpoint. where accepts @Name, an alternation @(A|B), or a @glob*; omit it to select every method. methods may also be an array of specs.
Requirement: templates must be valid TypeScript
Because a placeholder like $Name is itself a legal TypeScript identifier, every template is required to parse as valid TypeScript on its own. This is what lets Shapelint use one parser and no custom grammar. A template that fails to parse is reported as a config error when the config loads, not silently at check time.
Multiple acceptable shapes
A single pattern string describes one acceptable shape. When a convention has more than one legitimate form (a component as a plain arrow orReact.forwardRef), give pattern an array of templates, a file passes if it matches any one, and the closest variant is reported when none do. Equivalent type spellings (React.FC ≡ FC, Array<T> ≡ T[]) are folded by sameType.