Pular para o conteúdo

Escrevendo Regras

Regras são funções TypeScript que verificam seu codebase quanto à conformidade com ADRs. Elas ficam em arquivos .rules.ts complementares ao lado dos arquivos markdown dos ADRs e são executadas quando você roda archgate check.

.archgate/adrs/
ARCH-001-command-structure.md # The decision
ARCH-001-command-structure.rules.ts # The automated checks

Todo arquivo de regras exporta por padrão um objeto simples tipado com satisfies RuleSet. Cada chave no objeto rules se torna um ID de regra, e cada regra tem uma description e uma função async check que recebe um objeto de contexto.

/// <reference path="../rules.d.ts" />
export default {
rules: {
"my-rule-id": {
description: "What this rule checks",
async check(ctx) {
// Your check logic here
},
},
},
} satisfies RuleSet;

Um único arquivo de regras pode definir múltiplas regras:

/// <reference path="../rules.d.ts" />
export default {
rules: {
"first-rule": {
description: "Checks one thing",
async check(ctx) {
// ...
},
},
"second-rule": {
description: "Checks another thing",
async check(ctx) {
// ...
},
},
},
} satisfies RuleSet;

O objeto ctx passado para toda função check fornece capacidades de leitura de arquivos, busca e relatório. Aqui está uma referência detalhada com exemplos.

Um array de caminhos de arquivo que correspondem ao glob files do frontmatter do ADR. Se o ADR não tiver o campo files, isso inclui todos os arquivos do projeto.

for (const file of ctx.scopedFiles) {
const content = await ctx.readFile(file);
// Check content...
}

Use ctx.scopedFiles quando sua regra deve se aplicar apenas aos arquivos que o ADR governa. Por exemplo, uma regra de estrutura de comandos com escopo src/commands/**/*.ts receberá apenas arquivos de comandos.

Um array de caminhos de arquivo que diferem da branch base, incluindo alterações não commitadas da árvore de trabalho (arquivos staged, não staged e não rastreados que não sejam ignorados). Auto-detectado por padrão, ou preenchido via --staged / --base <ref>. Útil para verificação incremental e regras de dependência entre arquivos.

// Verificação incremental -- validar apenas arquivos alterados
const filesToCheck = ctx.scopedFiles.filter((f) =>
ctx.changedFiles.includes(f)
);
// Dependência entre arquivos -- se o arquivo A mudou, o arquivo B também deve mudar
if (ctx.changedFiles.includes("config/database.yml")) {
if (!ctx.changedFiles.includes("deploy/manifest.yml")) {
ctx.report.violation({
message: "config changed but manifest was not bumped",
file: "config/database.yml",
});
}
}

Lê o conteúdo de um arquivo como string. O caminho é relativo à raiz do projeto.

const content = await ctx.readFile("src/cli.ts");

Lê e faz o parse de um arquivo JSON. Retorna unknown — faça cast para o formato esperado.

const pkg = (await ctx.readJSON("package.json")) as {
dependencies?: Record<string, string>;
};

Busca em um único arquivo com uma expressão regular. Retorna um array de objetos GrepMatch, cada um com as propriedades file, line, column e content.

const matches = await ctx.grep(file, /console\.error\(/);
for (const match of matches) {
ctx.report.violation({
message: "Use logError() instead of console.error()",
file: match.file,
line: match.line,
});
}

Busca em múltiplos arquivos que correspondem a um padrão glob. Retorna um array plano de objetos GrepMatch de todos os arquivos correspondentes. Arquivos ignorados pelo .gitignore são excluídos por padrão. Defina respectGitignore: false no frontmatter do ADR para incluí-los.

const matches = await ctx.grepFiles(/TODO:/, "src/**/*.ts");
for (const match of matches) {
ctx.report.warning({
message: "TODO comment found",
file: match.file,
line: match.line,
});
}

Encontra arquivos por padrão glob. Retorna um array de caminhos de arquivo relativos à raiz do projeto. Arquivos ignorados pelo .gitignore são excluídos por padrão. Defina respectGitignore: false no frontmatter do ADR para incluí-los.

const testFiles = await ctx.glob("tests/**/*.test.ts");

Faz o parse de um arquivo-fonte para sua AST nativa da linguagem. Linguagens suportadas: "typescript", "javascript", "python" e "ruby". TypeScript e JavaScript são parseados in-process para um Program ESTree; Python e Ruby invocam como subprocesso o parser da biblioteca padrão do interpretador do sistema. Lança uma exceção em caso de falha de parse ou interpretador ausente — nunca retorna null.

const program = await ctx.ast("src/cli.ts", "typescript");

Um terceiro argumento opcional cobre mais duas necessidades:

  • { rev: "base" } faz o parse do arquivo na revisão base do git em vez da árvore de trabalho — combine com um parse da árvore de trabalho para detectar se a estrutura executável mudou (veja abaixo).
  • { comments: true } anexa um array comments estruturado à árvore (typescript/javascript/python), para que regras de governança de comentários trabalhem com tokens de comentário estruturados em vez de regex.

Veja Verificações estruturais com ctx.ast() abaixo para exemplos completos, e a Referência da API de Regras para o formato retornado por linguagem e as opções completas.

Lê o código-fonte de um arquivo na revisão base do git — a base de merge entre a ref --base e HEAD. Retorna null quando nenhuma base é resolvida (sem --base) ou o arquivo não existia na base (um arquivo adicionado), então uma única verificação de null cobre ambos os casos. Para uma comparação estrutural, prefira ctx.ast(path, language, { rev: "base" }).

const before = await ctx.fileAtBase("src/config.ts");
if (before !== null && before !== (await ctx.readFile("src/config.ts"))) {
// o arquivo mudou desde a base
}

A interface de relatório com três métodos de severidade:

  • ctx.report.violation(detail) — severidade error (código de saída 1, bloqueia CI)
  • ctx.report.warning(detail) — severidade warning (registrado, mas não bloqueia)
  • ctx.report.info(detail) — informacional (registrado para visibilidade)

Cada método aceita um objeto com:

CampoTipoObrigatórioDescrição
messagestringSimQual é a violação
filestringNãoCaminho do arquivo com a violação
linenumberNãoNúmero da linha da violação
fixstringNãoCorreção sugerida (exibida ao desenvolvedor)
ctx.report.violation({
message: "Command file must export a register*Command function",
file: "src/commands/check.ts",
line: 5,
fix: "Add: export function registerCheckCommand(program: Command) { ... }",
});

O caminho absoluto para o diretório raiz do projeto. Útil quando você precisa construir caminhos absolutos.

Regex funciona para padrões de superfície, mas falha em perguntas estruturais como “este arquivo contém apenas re-exports?” ou “isto é uma cláusula except: vazia?” — declarações multilinha, comentários e conteúdo de strings derrotam a correspondência baseada em linhas. ctx.ast() faz o parse de um arquivo para uma árvore de sintaxe real, permitindo que sua regra verifique a estrutura diretamente.

A árvore retornada é nativa da linguagem, não unificada entre linguagens:

  • TypeScript / JavaScript — um Program ESTree parseado in-process pelo meriyah. O TypeScript é transpilado primeiro, então sintaxe exclusivamente de tipos (interface, type aliases, export type { ... } from) é apagada da árvore; um arquivo contendo apenas declarações de nível de tipo parseia para um Program com body vazio.
  • Python — a árvore do módulo ast da biblioteca padrão serializada em JSON. Cada nó é um objeto com um campo _type mais os campos do próprio nó e as posições lineno / col_offset.
  • Ruby — a saída de Ripper.sexp do Ripper da biblioteca padrão: arrays aninhados como ["program", [["command", ...]]] com pares de posição [linha, coluna].

ctx.ast() lança uma exceção em caso de falha de parse ou de interpretador ausente — nunca retorna null. A exceção fica isolada à regra que falhou e aparece como um erro de execução de regra (código de saída 2), então um ambiente quebrado se manifesta como uma falha visível em vez de um falso sucesso.

Um barrel file é um index.ts cujas declarações de nível superior são todas re-exports. Com o Program ESTree, essa pergunta se torna uma verificação direta sobre os tipos das declarações, em vez de uma heurística de correspondência de linhas:

/// <reference path="../rules.d.ts" />
export default {
rules: {
"no-barrel-files": {
description: "index.ts files must not be pure re-export barrels",
async check(ctx) {
const indexFiles = ctx.scopedFiles.filter((f) =>
f.endsWith("/index.ts")
);
const checks = indexFiles.map(async (file) => {
const program = await ctx.ast(file, "typescript");
const isBarrel =
program.body.length > 0 &&
program.body.every(
(node) =>
node.type === "ExportAllDeclaration" ||
(node.type === "ExportNamedDeclaration" && node.source !== null)
);
if (isBarrel) {
ctx.report.violation({
message: `Barrel file detected: ${file} contains only re-exports`,
file,
fix: "Delete the barrel and import directly from the source modules",
});
}
});
await Promise.all(checks);
},
},
},
} satisfies RuleSet;

Como o TypeScript é transpilado antes do parse, re-exports export type { ... } from são apagados — um barrel contendo apenas re-exports de tipos parseia para um body vazio, que a guarda body.length > 0 ignora. Se você também precisa sinalizar barrels exclusivamente de tipos, combine a verificação de AST com uma verificação textual. A mesma transpilação também desloca posições: os números de linha em loc referem-se ao texto transpilado, não ao seu arquivo .ts original, então uma regra "typescript" precisa relocalizar a construção no código-fonte original (por exemplo com ctx.readFile() e indexOf) antes de reportar um line — ou omitir line.

Percorra a árvore ESTree em busca de nós CallExpression cujo callee é o identificador require. Um percurso recursivo sobre valores de objetos e arrays cobre todos os tipos de nó sem enumerá-los:

/// <reference path="../rules.d.ts" />
function findRequireCalls(node: unknown, lines: number[]): void {
if (Array.isArray(node)) {
for (const item of node) findRequireCalls(item, lines);
return;
}
if (node === null || typeof node !== "object") return;
const n = node as EsTreeNode;
const callee = n.callee as EsTreeNode | undefined;
if (
n.type === "CallExpression" &&
callee?.type === "Identifier" &&
callee.name === "require"
) {
if (n.loc) lines.push(n.loc.start.line);
}
for (const value of Object.values(n)) findRequireCalls(value, lines);
}
export default {
rules: {
"no-require-in-esm": {
description: ".mjs files must not call CommonJS require()",
async check(ctx) {
const files = await ctx.glob("src/**/*.mjs");
const checks = files.map(async (file) => {
const program = await ctx.ast(file, "javascript");
const lines: number[] = [];
findRequireCalls(program, lines);
for (const line of lines) {
ctx.report.violation({
message: "CommonJS require() call in an ES module",
file,
line,
fix: "Use a static import or await import() instead",
});
}
});
await Promise.all(checks);
},
},
},
} satisfies RuleSet;

O reporte via loc.start.line aqui é válido apenas porque "javascript" parseia o código-fonte original sem transpilação — uma regra "typescript" precisa localizar a linha no código-fonte original, já que seu loc refere-se à saída transpilada.

No módulo ast do Python, uma cláusula except: é um nó ExceptHandler cujo campo type guarda a expressão da exceção capturada — um except: vazio tem "type": null. Percorra a árvore JSON em busca desse formato:

/// <reference path="../rules.d.ts" />
function findBareExcepts(node: unknown, lines: number[]): void {
if (Array.isArray(node)) {
for (const item of node) findBareExcepts(item, lines);
return;
}
if (node === null || typeof node !== "object") return;
const n = node as PythonAstNode;
if (n._type === "ExceptHandler" && n.type === null) {
if (n.lineno !== undefined) lines.push(n.lineno);
}
for (const value of Object.values(n)) findBareExcepts(value, lines);
}
export default {
rules: {
"no-bare-except": {
description: "Python code must not use bare except: clauses",
async check(ctx) {
const files = await ctx.glob("**/*.py");
const checks = files.map(async (file) => {
const tree = await ctx.ast(file, "python");
const lines: number[] = [];
findBareExcepts(tree, lines);
for (const line of lines) {
ctx.report.violation({
message:
"Bare except: catches every exception, including SystemExit",
file,
line,
fix: "Catch a specific exception class, e.g. except ValueError:",
});
}
});
await Promise.all(checks);
},
},
},
} satisfies RuleSet;

O Ripper.sexp representa puts "hello" como ["command", ["@ident", "puts", [1, 0]], [...args]] — o par [linha, coluna] fica dentro do token @ident. Percorra os arrays aninhados em busca desse formato:

/// <reference path="../rules.d.ts" />
function findPutsCalls(node: unknown, lines: number[]): void {
if (!Array.isArray(node)) return;
const [kind, first] = node;
if (
kind === "command" &&
Array.isArray(first) &&
first[0] === "@ident" &&
first[1] === "puts"
) {
const [line] = first[2] as [number, number];
lines.push(line);
}
for (const child of node) findPutsCalls(child, lines);
}
export default {
rules: {
"no-puts": {
description: "Ruby code must use the application logger, not puts",
async check(ctx) {
const files = await ctx.glob("app/**/*.rb");
const checks = files.map(async (file) => {
const sexp = await ctx.ast(file, "ruby");
const lines: number[] = [];
findPutsCalls(sexp, lines);
for (const line of lines) {
ctx.report.violation({
message: "puts writes to stdout directly",
file,
line,
fix: "Replace puts with logger.info",
});
}
});
await Promise.all(checks);
},
},
},
} satisfies RuleSet;

O Ripper usa um formato de nó diferente para a forma com parênteses — puts("hello") aparece sob um par method_add_arg / fcall, em vez de command — então uma regra de produção casaria o token fcall da mesma maneira.

Algumas regras não se importam com o estado atual de um arquivo, mas com o que mudou. Um caso comum: dispensar uma exigência (um bump de versão, uma entrada no changelog) quando uma alteração é apenas de documentação — a estrutura executável permanece intocada e só comentários ou formatação se moveram. ctx.ast(path, language, { rev: "base" }) faz o parse do arquivo na revisão base do git, para que você possa compará-lo com o parse da árvore de trabalho. Comentários estão ausentes dos formatos ESTree e ast do Python — mas as posições dos nós não estão: os campos loc / lineno se deslocam quando um comentário ou linha em branco move as linhas abaixo dele, então uma serialização crua difere mesmo em uma edição apenas de documentação. Compare uma projeção sem posições das duas árvores. (Docstrings do Python são nós de string comuns na árvore ast, então uma docstring editada é uma mudança real da árvore — remova-as também se sua regra deve tratar edições de documentação como neutras.)

export default {
rules: {
"bump-required-on-behavior-change": {
description: "A .py change that alters behavior must bump the version",
async check(ctx) {
// Remove metadados de posição para comparar só a estrutura executável:
// um comentário ou linha em branco desloca lineno/col_offset sem mudar o
// comportamento. (Remova docstrings aqui também se edições de doc forem neutras.)
const POS = ["lineno", "col_offset", "end_lineno", "end_col_offset"];
const structure = (node: unknown): unknown => {
if (Array.isArray(node)) return node.map(structure);
if (node && typeof node === "object") {
return Object.fromEntries(
Object.entries(node)
.filter(([k]) => !POS.includes(k))
.map(([k, v]) => [k, structure(v)])
);
}
return node;
};
const changed = ctx.changedFiles.filter((f) => f.endsWith(".py"));
for (const file of changed) {
// Sem contraparte na base (ex.: um arquivo adicionado) -- pular.
if ((await ctx.fileAtBase(file)) === null) continue;
const before = await ctx.ast(file, "python", { rev: "base" });
const after = await ctx.ast(file, "python");
if (
JSON.stringify(structure(before)) !==
JSON.stringify(structure(after))
) {
ctx.report.violation({
message: `${file} changed behavior -- bump the version`,
file,
});
}
}
},
},
},
} satisfies RuleSet;

O archgate check resolve uma base automaticamente (ou aceita um --base <ref> explícito), e ctx.changedFiles é preenchido a partir dela — incluindo trabalho não committado. A guarda por arquivo é o que torna a regra segura: quando nenhuma base é resolvida, ou para um arquivo ausente na base, ctx.fileAtBase() retorna null e o laço pula esse arquivo. Como o parse da base passa por ctx.ast() e seu acesso ao git é embutido no Archgate, a regra nunca executa git ou um interpretador por conta própria — o que o sandbox de regras bloquearia de qualquer forma.

ctx.ast(path, language, { comments: true }) anexa um array comments à árvore, para que regras de política de comentários trabalhem com tokens estruturados (type, value, loc) em vez de regex linha a linha. As posições dos comentários são precisas em relação ao código-fonte original mesmo para TypeScript. Suportado para typescript, javascript e python.

const tree = await ctx.ast(file, "typescript", { comments: true });
for (const comment of tree.comments ?? []) {
if (comment.value.split("\n").length > 10) {
ctx.report.warning({
message: "Comment block too long -- move rationale to an ADR",
file,
line: comment.loc.start.line,
});
}
}

Cada regra pode definir uma severidade padrão em sua configuração. A severidade determina como as violações são tratadas:

SeveridadeCódigo de saídaComportamento
error1Bloqueia CI, deve ser corrigido
warning0Registrado, mas não bloqueia
info0Informacional, registrado para visibilidade

Defina a severidade na definição da regra:

export default {
rules: {
"my-rule": {
description: "...",
severity: "warning",
async check(ctx) {
// Violations from this rule are warnings, not errors
ctx.report.violation({ message: "..." });
},
},
},
} satisfies RuleSet;

Se severity for omitido, o padrão é error.

Você também pode reportar com diferentes severidades dentro da mesma regra usando ctx.report.violation(), ctx.report.warning() e ctx.report.info() diretamente.

Cada regra tem um timeout de execução de 30 segundos. Se uma regra exceder esse limite, ela é tratada como erro. Isso impede que verificações descontroladas bloqueiem o pipeline.

Mantenha as regras rápidas:

  • Usando ctx.grepFiles() ao invés de ler cada arquivo manualmente
  • Usando Promise.all() para verificar arquivos em paralelo
  • Delimitando regras com o campo files do frontmatter para limitar o número de arquivos processados

O campo fix é uma string opcional exibida ao desenvolvedor junto com a mensagem de violação. Ele descreve qual ação tomar para resolver o problema. Correções não são aplicadas automaticamente — são orientações.

ctx.report.violation({
message: `Unapproved dependency: "chalk"`,
file: "package.json",
fix: "Use styleText() from node:util instead of chalk",
});

Quando exibido, o fix aparece abaixo da mensagem de violação:

ARCH-006/no-unapproved-deps
package.json
Unapproved dependency: "chalk"
Fix: Use styleText() from node:util instead of chalk
  1. Use Promise.all() para verificações de arquivo em paralelo. Ao verificar múltiplos arquivos independentes, processe-os em paralelo ao invés de sequencialmente.

    // Good: parallel
    const checks = files.map(async (file) => {
    const content = await ctx.readFile(file);
    // ...
    });
    await Promise.all(checks);
    // Avoid: sequential
    for (const file of files) {
    const content = await ctx.readFile(file);
    // ...
    }
  2. Use ctx.changedFiles para verificação incremental. ctx.changedFiles é auto-preenchido com o diff da branch mais as alterações não commitadas da árvore de trabalho (ou arquivos staged com --staged). Filtre ctx.scopedFiles com ele para verificar apenas o que mudou, ou use-o diretamente para regras de dependência entre arquivos.

  3. Mantenha as regras focadas em uma única preocupação. Uma regra que verifica tanto convenções de nomenclatura quanto padrões de import deve ser dividida em duas regras com IDs separados.

  4. Use ctx.grepFiles() ao invés de iteração manual. Ao buscar um padrão em muitos arquivos, ctx.grepFiles() é mais eficiente do que ler cada arquivo e executar uma regex.

  5. Forneça mensagens de fix acionáveis. Um fix como “Não faça isso” não é útil. Diga ao desenvolvedor exatamente o que fazer.

  6. Filtre arquivos não-aplicáveis cedo. Se sua regra se aplica apenas a certos arquivos dentro do escopo, filtre ctx.scopedFiles antes de processar:

    const commandFiles = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts"));
  7. Trate arquivos ausentes com elegância. Se sua regra lê um arquivo específico como package.json, envolva a leitura em um try/catch e retorne antecipadamente se o arquivo não existir.

Existem duas formas de lidar com exceções no Archgate: supressão no nível do engine (funciona com qualquer regra automaticamente) e diretivas customizadas no nível da regra (implementadas pelo autor da regra para opt-outs específicos do domínio).

O Archgate suporta comentários inline archgate-ignore que suprimem violações sem modificar a regra em si. O engine analisa esses comentários e filtra as violações correspondentes antes de reportar.

Supressão da próxima linha: suprime a violação na linha imediatamente seguinte:

// archgate-ignore ARCH-006/no-unapproved-deps dep legada, migração planejada para Q3
import chalk from "chalk";

Supressão no nível do arquivo: suprime todas as violações correspondentes em qualquer lugar do arquivo:

// archgate-ignore-file ARCH-005/test-mirrors-src arquivo gerado, sem teste manual

Múltiplas regras: empilhe comentários para suprimir mais de uma regra na mesma linha:

// archgate-ignore ARCH-006/no-unapproved-deps dep legada
// archgate-ignore ARCH-003/use-style-text lib de terceiros lida com cores
import chalk from "chalk";

Comentários de supressão consecutivos todos visam a primeira linha que não é uma supressão após o bloco.

O formato é ADR-ID/rule-id seguido de um motivo. O motivo é obrigatório: uma supressão sem motivo é ignorada e produz um aviso:

[suppression] Suppression for ARCH-006/no-unapproved-deps is missing a reason src/foo.ts:1

Ambos os estilos de comentário // e # são suportados, então as supressões funcionam em TypeScript, JavaScript, YAML, Python, shell scripts e outros tipos de arquivo que suas regras podem analisar.

Para opt-outs específicos do domínio, autores de regras podem implementar suas próprias diretivas baseadas em comentários dentro da função check. Esse padrão dá à regra controle total sobre a sintaxe, posicionamento e validação da diretiva.

// No seu arquivo .rules.ts:
async check(ctx) {
const files = await ctx.glob("src/components/**/*Connected.tsx");
for (const file of files) {
const content = await ctx.readFile(file);
// Suportar diretiva de opt-out no topo do arquivo
if (/^\/\/\s*@no-presentational:/u.test(content.trimStart())) continue;
// ... lógica da regra que pode reportar uma violação ...
ctx.report.violation({
message: "Componente presentational ausente",
file,
fix: 'Adicione "// @no-presentational: <motivo>" no topo do arquivo para fazer opt-out',
});
}
}

O desenvolvedor faz opt-out adicionando a diretiva ao seu arquivo:

// @no-presentational: este componente apenas redireciona, sem UI para renderizar
import { useNavigate } from "react-router";
AbordagemMelhor paraQuem controla
archgate-ignoreExceções ad-hoc para qualquer regraDesenvolvedor usando a regra
Diretiva customizadaOpt-outs específicos do domínio com motivos estruturadosAutor da regra

Use archgate-ignore quando um desenvolvedor precisa suprimir uma violação pontual. Use diretivas customizadas quando o opt-out é um conceito de primeira classe no domínio da sua regra. Por exemplo, marcar um componente como intencionalmente sem par, ou um arquivo como auto-gerado.

  • Padrões Comuns de Regras: padrões prontos para copiar e colar, organizados por categoria: gerenciamento de dependências, restrições de import, estrutura de arquivos, qualidade de código, esquema de banco de dados e limites de arquitetura.
  • Referência da API de Regras: referência completa de todos os tipos e funções da API de regras.
  • Integração com CI: integre archgate check ao seu pipeline para aplicar regras em cada PR.