Pular para o conteúdo

API de Regras

As regras do Archgate são arquivos TypeScript que exportam um objeto simples tipado com satisfies RuleSet. Cada regra recebe um RuleContext com utilitários para buscar arquivos, ler conteúdo e reportar violações.

/// <reference path="../rules.d.ts" />
export default {
rules: {
"my-rule-id": {
description: "Human-readable description of what this rule checks",
severity: "error", // optional, defaults to "error"
async check(ctx) {
// Rule logic here
},
},
},
} satisfies RuleSet;

Um arquivo de regras exporta por padrão um objeto simples com um registro rules indexado por ID de regra. As chaves se tornam os IDs das regras que aparecem na saída de verificação e nos relatórios de violação. A anotação satisfies RuleSet fornece verificação de tipos sem envolver em uma chamada de função.

type RuleSet = { rules: Record<string, RuleConfig> };

Cada regra no registro deve estar em conformidade com a interface RuleConfig.

interface RuleConfig {
description: string;
severity?: Severity;
check: (ctx: RuleContext) => Promise<void>;
}
CampoTipoObrigatórioDescrição
descriptionstringSimDescrição legível exibida na saída de verificação
severitySeverityNãoSeveridade padrão para violações. Padrão: "error"
check(ctx: RuleContext) => Promise<void>SimFunção assíncrona contendo a lógica da regra

A função check recebe um objeto RuleContext com o estado do projeto e métodos utilitários.

interface RuleContext {
projectRoot: string;
scopedFiles: string[];
changedFiles: string[];
glob(pattern: string): Promise<string[]>;
grep(file: string, pattern: RegExp): Promise<GrepMatch[]>;
grepFiles(pattern: RegExp, fileGlob: string): Promise<GrepMatch[]>;
readFile(path: string): Promise<string>;
fileAtBase(path: string): Promise<string | null>;
readJSON(path: string): Promise<unknown>;
readYAML(path: string): Promise<ReadYamlResult>;
ast(path: string, language: AstLanguage, opts?: AstOptions): Promise<AstNode>;
findAstNodes(tree: AstNode, ...types: string[]): AstNode[];
checkCase(value: string, scheme: CaseScheme): boolean;
report: RuleReport;
}
projectRoot: string;

Caminho absoluto para o diretório raiz do projeto (onde .archgate/ está localizado).

scopedFiles: string[];

Arquivos que correspondem aos padrões glob de files do frontmatter do ADR. Se o ADR não tiver o campo files, contém todos os arquivos do projeto. Use esta lista como a lista principal de arquivos para suas verificações de regra.

changedFiles: string[];

Arquivos modificados conforme o Git. Por padrão, é autopreenchido com o diff da branch em relação à branch base detectada (ex.: origin/main) mais quaisquer alterações não commitadas da árvore de trabalho (arquivos staged, não staged e não rastreados que não sejam ignorados). Quando --staged é usado, contém apenas arquivos staged. Quando --base <ref> é usado, contém todos os arquivos alterados desde essa ref mais as alterações não commitadas da árvore de trabalho. Fica vazio quando a detecção falha ou nenhuma alteração é encontrada. Use para construir regras de dependência entre arquivos (ex.: “se o arquivo A mudou, o arquivo B também deve mudar”).

report: RuleReport;

A interface de relatório para registrar violações, avisos e mensagens informativas. Veja RuleReport abaixo.

glob(pattern: string): Promise<string[]>;

Encontra arquivos correspondentes a um padrão glob relativo à raiz do projeto. Retorna um array de caminhos de arquivo. 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");
grep(file: string, pattern: RegExp): Promise<GrepMatch[]>;

Busca em um único arquivo por linhas correspondentes a uma expressão regular. Retorna um array de objetos GrepMatch com caminho do arquivo, número da linha, coluna e conteúdo correspondente.

const matches = await ctx.grep(file, /console\.error\(/);
grepFiles(pattern: RegExp, fileGlob: string): Promise<GrepMatch[]>;

Busca em múltiplos arquivos correspondentes a um padrão glob por linhas que correspondam a uma expressão regular. Combina glob e grep em uma única chamada. 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:/i, "src/**/*.ts");
readFile(path: string): Promise<string>;

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

const content = await ctx.readFile("src/config.ts");
fileAtBase(path: string): Promise<string | null>;

Lê o código-fonte de um arquivo na revisão base de comparação — a base de merge entre a ref --base e HEAD, o mesmo commit contra o qual changedFiles é calculado. Use para comparar a árvore de trabalho com o ponto em que o conjunto de alterações divergiu da base.

Retorna null nos dois casos de “nada para comparar”, então uma única verificação de null cobre ambos:

  • Nenhuma base é resolvida — a verificação rodou sem --base (ou os históricos não têm relação).
  • O arquivo não existia na base — um arquivo adicionado.
const before = await ctx.fileAtBase("data/schema.py");
if (before === null) {
// Nenhuma versão base para comparar -- pular.
return;
}
const after = await ctx.readFile("data/schema.py");

Para uma comparação estrutural (ignorando comentários e formatação) prefira ast(path, language, { rev: "base" }) abaixo.

readJSON(path: string): Promise<unknown>;

Lê e faz o parse de um arquivo JSON. O caminho é relativo à raiz do projeto. Retorna o valor parseado como unknown — faça o cast para o tipo esperado na sua regra.

const pkg = (await ctx.readJSON("package.json")) as {
dependencies?: Record<string, string>;
};
readYAML(path: string): Promise<ReadYamlResult>;
interface ReadYamlResult {
frontmatter: Record<string, YamlValue> | null;
content: YamlValue;
}
type YamlValue =
| string
| number
| boolean
| null
| YamlValue[]
| { [key: string]: YamlValue };

Lê um arquivo YAML ou um arquivo Markdown com frontmatter YAML. O caminho é relativo à raiz do projeto e passa pelo mesmo sandbox de readFile. Um único objeto de resultado cobre as duas formas — um mapeamento frontmatter anulável e um content do tipo YamlValue, os dados JSON-like que o esquema central do YAML produz (após uma verificação de typeof você pode indexar mapeamentos e sequências sem cast) — e a escolha é baseada na extensão do arquivo:

  • Arquivos .yml / .yaml: o documento inteiro é parseado como YAML. frontmatter é sempre null; content é o valor parseado, restringido por uma verificação de typeof em vez de um cast — diferente de readJSON, que retorna unknown. Como a escolha é por extensão, os separadores --- de um stream multi-documento (manifestos Kubernetes, configurações de CI) nunca são interpretados erroneamente como frontmatter.
  • Qualquer outro arquivo (tipicamente Markdown): o bloco inicial delimitado por --- é parseado como frontmatternull quando ausente (“este arquivo tem frontmatter?” é um único teste de null, espelhando fileAtBase), {} quando existe mas está vazio. content é o restante do corpo do texto, sem espaços nas extremidades — ele não é parseado como YAML.

readYAML() lança uma exceção (fail-closed, como ast()) quando um arquivo .yml/.yaml é YAML inválido, ou quando um bloco de frontmatter é YAML inválido ou o parse resulta em algo diferente de um mapeamento (um escalar ou uma sequência) — aparecendo como um erro de execução de regra com código de saída 2 em vez de uma aprovação falsa.

const { content } = await ctx.readYAML(".github/workflows/ci.yml");
if (
typeof content === "object" &&
content !== null &&
!Array.isArray(content)
) {
const jobs = content.jobs; // YamlValue -- no cast needed
}
for (const file of await ctx.glob("docs/**/*.md")) {
const { frontmatter } = await ctx.readYAML(file);
if (frontmatter === null) {
ctx.report.violation({ message: `${file} is missing frontmatter`, file });
continue;
}
if (typeof frontmatter.title !== "string") {
ctx.report.violation({
message: `${file} frontmatter must declare a title`,
file,
});
}
}
ast(
path: string,
language: "typescript" | "javascript" | "python" | "ruby",
opts?: AstOptions
): Promise<AstNode>;
interface AstOptions {
rev?: "base";
comments?: boolean;
}

Faz o parse de um arquivo-fonte para sua AST nativa da linguagem. O caminho é relativo à raiz do projeto e passa pelo mesmo sandbox de readFile. TypeScript e JavaScript são parseados in-process; Python e Ruby são parseados invocando como subprocesso o recurso de AST da biblioteca padrão do próprio interpretador do sistema. O formato da árvore retornada difere por linguagem — veja AstNode.

Os resultados de parse são cacheados durante uma única execução de archgate check, com chave (path, language, rev, comments): chamadas idênticas repetidas — até mesmo concorrentes — entre regras custam um único parse (uma única inicialização do interpretador para Python/Ruby), e um parse que falhou relança o mesmo erro para todos os chamadores. Trate a árvore retornada como somente leitura — ela pode ser compartilhada com outras regras.

const program = await ctx.ast("src/cli.ts", "typescript");
for (const node of program.body) {
console.log(node.type);
}

Com { rev: "base" }, ast() faz o parse do arquivo na revisão base de comparação em vez da árvore de trabalho — tudo o mais (formato de retorno, contrato de exceção) é idêntico. Faça o parse das duas revisões para perguntar “a estrutura executável mudou?” 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. Compare uma projeção sem posições — remova loc / range (ESTree) e lineno / col_offset (Python) antes de comparar — para que uma edição apenas de comentários seja lida como inalterada. (Docstrings do Python são nós de string na árvore, então uma docstring editada é uma mudança real; remova-as também se edições de documentação forem neutras.)

// Sinaliza uma mudança apenas quando a estrutura executável de fato mudou.
// `structurallyEqual` compara as árvores com os metadados de posição (loc/range,
// lineno/col_offset) removidos -- veja o guia writing-rules para uma
// implementação concreta.
for (const file of ctx.changedFiles.filter((f) => f.endsWith(".py"))) {
// Pula arquivos sem contraparte na base -- ast({ rev: "base" }) lançaria.
if ((await ctx.fileAtBase(file)) === null) continue;
const before = await ctx.ast(file, "python", { rev: "base" });
const after = await ctx.ast(file, "python");
if (!structurallyEqual(before, after)) {
ctx.report.violation({ message: `${file} changed behavior`, file });
}
}

Recorra a fileAtBase() primeiro quando você precisar detectar os casos de sem-base ou arquivo-adicionado como fluxo de controle comum — ast({ rev: "base" }) lança uma exceção para eles (veja abaixo).

Com { comments: true }, a árvore retornada carrega um array comments — dados estruturados de comentário para regras de governança de comentários, no lugar de regex linha a linha. Suportado para as quatro linguagens. Para ruby, a árvore retornada é um array (saída de Ripper.sexp), então o array comments fica anexado a ela como uma propriedade não indexada.

interface CommentToken {
type: "line" | "block";
value: string; // delimitadores (`//`, `/* */`, `#`) removidos
loc: {
start: { line: number; column: number };
end: { line: number; column: number };
};
}
const tree = await ctx.ast("src/api.ts", "typescript", { comments: true });
for (const comment of tree.comments ?? []) {
const lines = comment.value.split("\n").length;
if (lines > 10) {
ctx.report.warning({
message: "Comment block is too long -- link to an ADR instead",
file: "src/api.ts",
line: comment.loc.start.line,
});
}
}

As posições dos comentários são precisas em relação ao código-fonte original, mesmo para TypeScript. Essa é uma vantagem deliberada em relação ao loc da própria árvore, que é relativo ao transpilado para TypeScript (veja AstNode): os comentários são varridos a partir do código-fonte antes da transpilação, então seu loc nunca diverge. Comentários em Python são sempre type: "line" (#) — Python não tem comentários de bloco, e docstrings """ são expressões de string na árvore, não comentários. Comentários # em Ruby são type: "line"; cada bloco de documentação =begin/=end é um único token type: "block" cujo value é o conteúdo interno (as linhas marcadoras =begin/=end são removidas, de forma análoga à remoção dos delimitadores /* */) e cujo loc vai da linha do =begin até a linha do =end. As colunas de loc dos comentários Ruby são deslocamentos de caracteres, consistentes com as demais linguagens (as posições de nó do próprio sexp do Ripper são deslocamentos de bytes), e as quebras de linha do value de blocos são normalizadas para LF independentemente das quebras de linha do arquivo-fonte. Para Python e Ruby, a extração de comentários é uma segunda passada sobre o mesmo código-fonte (tokenize / Ripper.lex); uma falha do tokenizador em código que parseia normalmente degrada para uma lista de comentários vazia em vez de falhar o parse. O scanner de TypeScript/JavaScript reconhece literais de string e de template, mas não rastreia literais de expressão regular, então um delimitador de comentário dentro de um literal regex é um ponto cego conhecido.

ast() lança uma exceção em caso de falha — nunca retorna null:

  • Falha de parse: o arquivo não parseia como a linguagem solicitada. A mensagem de erro inclui o diagnóstico do parser.
  • Interpretador ausente (apenas python/ruby): nenhum interpretador adequado foi encontrado no PATH.
  • Entrada implausível: a extensão do arquivo não corresponde à linguagem solicitada (ex.: ctx.ast("config.json", "python") lança a exceção antes de qualquer interpretador ser invocado).
  • Sem revisão base (apenas { rev: "base" }): a verificação rodou sem --base, então não há base para parsear. Use fileAtBase() para detectar isso como null em vez disso.
  • Arquivo ausente na base (apenas { rev: "base" }): o caminho foi adicionado desde a base e não existia ali.

Um erro lançado é isolado à regra que falhou: as demais regras e ADRs da mesma execução de verificação continuam normalmente, e a falha aparece como um erro de execução de regra com código de saída 2 (distinto do código de saída 1 para violações). Os casos de exceção são distinguíveis pelo texto da mensagem, então a saída da verificação diferencia “este ambiente não consegue executar esta regra” de “este arquivo tem um erro de sintaxe” ou “não há revisão base”.

findAstNodes(tree: AstNode, ...types: string[]): AstNode[];

Coleta recursivamente cada nó de uma AST parseada cujo campo discriminante de tipo corresponde a um dos types. É a substituição embutida para o caminhador recursivo que as regras de AST precisavam escrever à mão: ctx.ast() retorna deliberadamente formatos nativos da linguagem, mas encontrar nós por nome de tipo depende apenas do campo discriminante, então um único helper cobre todas as linguagens. Síncrono — não precisa de await.

  • Agnóstico de linguagem: cada nó-objeto é verificado contra o campo discriminante que ele carrega — _type (Python) ou type (ESTree TypeScript/JavaScript).
  • Travessia completa: valores de objeto enumeráveis próprios e arrays são percorridos recursivamente, e o próprio argumento tree é um candidato a correspondência.
  • Correspondência de múltiplos tipos: passe vários nomes quando uma construção abrange múltiplos tipos de nó — o caso comum ("FunctionDef"/"AsyncFunctionDef", variantes síncrona/assíncrona).
  • Ruby: nós de Ripper.sexp são arrays puros sem campo discriminante de objeto, então uma árvore Ruby é percorrida, mas seus nós em formato de array nunca correspondem — percorra a saída do Ripper contra a gramática dele.

Antes — o coletor que cada arquivo de regras precisava repetir (arquivos de regras não podem importar módulos helper compartilhados):

function collectFunctionDefs(
node: unknown,
out: PythonAstNode[] = []
): PythonAstNode[] {
if (Array.isArray(node)) {
for (const item of node) collectFunctionDefs(item, out);
return out;
}
if (!node || typeof node !== "object") return out;
const n = node as PythonAstNode;
if (n._type === "FunctionDef" || n._type === "AsyncFunctionDef") out.push(n);
for (const value of Object.values(n)) {
if (value && typeof value === "object") collectFunctionDefs(value, out);
}
return out;
}
const tree = await ctx.ast("app/models.py", "python");
const funcDefs = collectFunctionDefs(tree);

Depois:

const tree = await ctx.ast("app/models.py", "python");
const funcDefs = ctx.findAstNodes(tree, "FunctionDef", "AsyncFunctionDef");
checkCase(value: string, scheme: CaseScheme): boolean;
type CaseScheme =
| "kebab-case"
| "camelCase"
| "PascalCase"
| "snake_case"
| "SCREAMING_SNAKE_CASE";

Verifica se uma string segue um esquema de capitalização — a substituição integrada para os regexes por esquema que regras de nomenclatura precisavam escrever à mão. Síncrono e puro — não precisa de await. A correspondência é tudo-ou-nada (a string inteira deve estar em conformidade; a string vazia não corresponde a nenhum esquema) e o vocabulário é apenas letras e dígitos ASCII.

EsquemaAceitaRejeita
kebab-casewriting-rules, 2fa-setupWriting-Rules, writing_rules, writing--rules
camelCasecheckCase, parseURLCheckCase, check_case, 2fast
PascalCaseCheckCase, HTTPServercheckCase, Check_Case, 1Value
snake_casecheck_case, 2fa_setupCheck_Case, check-case, check__case
SCREAMING_SNAKE_CASECHECK_CASE, V2check_case, CHECK-CASE, CHECK__CASE

camelCase e PascalCase seguem a convenção do ecossistema (a naming-convention do typescript-eslint): uma letra inicial minúscula/maiúscula seguida de quaisquer caracteres alfanuméricos ASCII, então sequências de acrônimos (parseURL, HTTPServer) correspondem. Valores degenerados podem satisfazer vários esquemas ao mesmo tempo (value é kebab-case, snake_case e camelCase válidos). Passar um nome de esquema não reconhecido lança uma exceção em vez de retornar false silenciosamente, para que um erro de digitação apareça como um erro de regra em vez de um resultado falso.

for (const file of await ctx.glob("docs/**/*.md")) {
const stem = file.split("/").pop()?.replace(/\.md$/, "") ?? "";
if (!ctx.checkCase(stem, "kebab-case")) {
ctx.report.violation({
message: `${file} must have a kebab-case filename`,
file,
});
}
}

A interface de relatório para registrar resultados de verificação. Cada método aceita um objeto de detalhe descrevendo o problema.

interface RuleReport {
violation(detail: ReportDetail): void;
warning(detail: ReportDetail): void;
info(detail: ReportDetail): void;
}
report.violation(detail: ReportDetail): void;

Reporta uma violação de regra. Violações fazem a verificação falhar com código de saída 1. Use para restrições rígidas que não devem ser mergeadas.

report.warning(detail: ReportDetail): void;

Reporta um aviso. Avisos aparecem na saída de verificação mas não fazem a verificação falhar. Use para orientações não bloqueantes.

report.info(detail: ReportDetail): void;

Reporta uma mensagem informativa. Não afeta o código de saída da verificação. Use para sugestões ou notas.

O objeto de detalhe passado para violation, warning e info.

interface ReportDetail {
message: string;
file?: string;
line?: number;
endLine?: number;
endColumn?: number;
fix?: string;
}
CampoTipoObrigatórioDescrição
messagestringSimDescrição legível do problema
filestringNãoCaminho do arquivo onde o problema foi encontrado
linenumberNãoNúmero da linha inicial (base 1)
endLinenumberNãoNúmero da linha final (base 1), para destaque preciso no editor
endColumnnumberNãoNúmero da coluna final (base 0), para destaque preciso no editor
fixstringNãoSugestão de correção ou ação de remediação

Quando endLine e endColumn são fornecidos, editores (VS Code, Cursor) podem destacar a expressão exata que viola a regra, em vez da linha inteira. Se omitidos, a linha completa em line é destacada.


Retornado por ctx.grep() e ctx.grepFiles().

interface GrepMatch {
file: string;
line: number;
column: number;
content: string;
}
CampoTipoDescrição
filestringCaminho relativo ao projeto do arquivo correspondente
linenumberNúmero da linha da correspondência (base 1)
columnnumberNúmero da coluna da correspondência (base 1)
contentstringConteúdo completo da linha correspondente

Retornado por ctx.ast(). O formato é nativo da linguagem e deliberadamente não é unificado entre linguagens — cada linguagem retorna seu próprio vocabulário padrão de AST, então uma regra que inspeciona código Python trabalha com uma gramática diferente de uma que inspeciona TypeScript.

type AstLanguage = "typescript" | "javascript" | "python" | "ruby";
type AstNode = Record<string, unknown> | unknown[];

Quando parseado com { comments: true }, o nó raiz também carrega um array comments: CommentToken[] (as quatro linguagens; para ruby ele é uma propriedade não indexada no array sexp raiz). Ausente nos demais casos.

LinguagemParser utilizadoFormato retornado
typescriptmeriyah, in-process, após remover o TypeScript por transpilação com Bun.TranspilerProgram ESTree com informações de posição em loc. Sintaxe exclusivamente de tipos (interface, type aliases, export type { ... } from) é apagada antes do parse — um arquivo contendo apenas declarações de nível de tipo parseia para um Program com body vazio. As posições em loc referem-se à saída transpilada, não ao arquivo .ts original — declarações exclusivamente de tipos removidas, comentários e linhas em branco fazem os números de linha divergirem, então relocalize a construção no código-fonte original (p. ex. ctx.readFile() mais indexOf) antes de reportar um line, ou omita line por completo; loc é fiel ao código-fonte apenas para javascript
javascriptmeriyah, in-processProgram ESTree com informações de posição em loc
pythonO módulo ast da biblioteca padrão do Python, via interpretador do sistemaNós do ast serializados em JSON: { "_type": "Module", "body": [...] } — cada nó carrega _type, os campos do próprio nó e as posições lineno / col_offset
rubyO Ripper da biblioteca padrão do Ruby, via interpretador do sistemaArrays aninhados de Ripper.sexp: ["program", [["command", ...]]] com pares de posição [linha, coluna] embutidos nas entradas de token

Veja Verificações estruturais com ctx.ast() para um exemplo completo de regra por linguagem.


type Severity = "error" | "warning" | "info";
ValorImpacto no código de saídaDescrição
"error"Causa saída 1Restrição rígida, bloqueia merges
"warning"Sem impactoOrientação não bloqueante
"info"Sem impactoInformativo, apenas sugestões

A representação interna de um problema reportado, usada na saída de verificação e nos resultados JSON.

interface ViolationDetail {
ruleId: string;
adrId: string;
message: string;
file?: string;
line?: number;
endLine?: number;
endColumn?: number;
fix?: string;
severity: Severity;
}
CampoTipoDescrição
ruleIdstringID da regra da chave do objeto rules
adrIdstringID do ADR do frontmatter
messagestringDescrição legível
filestring?Caminho do arquivo onde o problema foi encontrado
linenumber?Número da linha inicial (base 1)
endLinenumber?Linha final (base 1), para destaque preciso no editor
endColumnnumber?Coluna final (base 0), para destaque preciso no editor
fixstring?Sugestão de correção
severitySeveritySeveridade efetiva desta violação

Violações podem ser suprimidas no código-fonte usando comentários archgate-ignore. O engine lida com isso automaticamente. As regras não precisam de nenhuma lógica especial.

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

Um motivo é obrigatório. A supressão no nível do arquivo usa archgate-ignore-file. Empilhe múltiplos comentários para suprimir mais de uma regra na mesma linha. Veja Diretivas de opt-out para detalhes completos e padrões de diretivas customizadas.