Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5221,6 +5221,7 @@ interface RuleContext {
fileAtBase(path: string): Promise<string | null>;
readJSON(path: string): Promise<unknown>;
ast(path: string, language: AstLanguage, opts?: AstOptions): Promise;
findAstNodes(tree: AstNode, ...types: string[]): AstNode[];
report: RuleReport;
}
```
Expand Down Expand Up @@ -5441,6 +5442,50 @@ A thrown error is isolated to the failing rule: other rules and ADRs in the same
:::caution
Python and Ruby rules require the corresponding interpreter (`python3`/`python`, `ruby`) on `PATH` wherever `archgate check` runs -- on every developer machine **and** in CI. TypeScript and JavaScript parsing is built into Archgate and needs no interpreter.

#### findAstNodes

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

Recursively collect every node in a parsed AST whose type-discriminant field matches one of `types`. This is the built-in replacement for the recursive walker AST rules used to hand-roll: `ctx.ast()` deliberately returns language-native shapes, but finding nodes by type name only depends on the discriminant field, so one helper covers all languages. Synchronous -- no `await` needed.

- **Language-agnostic**: each object node is checked against whichever discriminant field it carries -- `_type` (Python) or `type` (ESTree TypeScript/JavaScript).
- **Full traversal**: own-enumerable object values and arrays are recursed, and the `tree` argument itself is a match candidate.
- **Multi-type matching**: pass several names when one construct spans multiple node types -- the common case (`"FunctionDef"`/`"AsyncFunctionDef"`, sync/async variants).
- **Ruby**: `Ripper.sexp` nodes are plain arrays with no object discriminant field, so a Ruby tree is recursed but its array-shaped nodes never match -- walk Ripper output against its own grammar instead.

Before -- the collector each rule file had to repeat (rule files cannot import shared helper modules):

```typescript
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);
```

After:

```typescript
const tree = await ctx.ast("app/models.py", "python");
const funcDefs = ctx.findAstNodes(tree, "FunctionDef", "AsyncFunctionDef");
```

---

## RuleReport
Expand Down
45 changes: 45 additions & 0 deletions docs/src/content/docs/nb/reference/rule-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ interface RuleContext {
fileAtBase(path: string): Promise<string | null>;
readJSON(path: string): Promise<unknown>;
ast(path: string, language: AstLanguage, opts?: AstOptions): Promise<AstNode>;
findAstNodes(tree: AstNode, ...types: string[]): AstNode[];
report: RuleReport;
}
```
Expand Down Expand Up @@ -288,6 +289,50 @@ En kastet feil er isolert til regelen som feiler: andre regler og ADR-er i samme
Python- og Ruby-regler krever at den tilhørende tolken (`python3`/`python`, `ruby`) finnes på `PATH` overalt der `archgate check` kjører -- på hver utviklermaskin **og** i CI. TypeScript- og JavaScript-parsing er innebygd i Archgate og trenger ingen tolk.
:::

#### findAstNodes

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

Samler rekursivt inn hver node i en parset AST der typediskriminantfeltet matcher en av `types`. Dette er den innebygde erstatningen for den rekursive vandreren AST-regler tidligere måtte håndrulle: `ctx.ast()` returnerer bevisst språknative former, men å finne noder etter typenavn avhenger bare av diskriminantfeltet, så én hjelpemetode dekker alle språk. Synkron -- ingen `await` nødvendig.

- **Språkagnostisk**: hver objektnode sjekkes mot det diskriminantfeltet den faktisk bærer -- `_type` (Python) eller `type` (ESTree TypeScript/JavaScript).
- **Full traversering**: egne enumererbare objektverdier og arrayer traverseres rekursivt, og selve `tree`-argumentet er en matchkandidat.
- **Matching av flere typer**: send inn flere navn når én konstruksjon spenner over flere nodetyper -- det vanlige tilfellet (`"FunctionDef"`/`"AsyncFunctionDef"`, synkrone/asynkrone varianter).
- **Ruby**: `Ripper.sexp`-noder er rene arrayer uten objektdiskriminantfelt, så et Ruby-tre traverseres, men de array-formede nodene matcher aldri -- gå gjennom Ripper-utdata mot dens egen grammatikk i stedet.

Før -- innsamleren hver regelfil måtte gjenta (regelfiler kan ikke importere delte hjelpemoduler):

```typescript
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);
```

Etter:

```typescript
const tree = await ctx.ast("app/models.py", "python");
const funcDefs = ctx.findAstNodes(tree, "FunctionDef", "AsyncFunctionDef");
```

---

## RuleReport
Expand Down
45 changes: 45 additions & 0 deletions docs/src/content/docs/pt-br/reference/rule-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ interface RuleContext {
fileAtBase(path: string): Promise<string | null>;
readJSON(path: string): Promise<unknown>;
ast(path: string, language: AstLanguage, opts?: AstOptions): Promise<AstNode>;
findAstNodes(tree: AstNode, ...types: string[]): AstNode[];
report: RuleReport;
}
```
Expand Down Expand Up @@ -288,6 +289,50 @@ Um erro lançado é isolado à regra que falhou: as demais regras e ADRs da mesm
Regras de Python e Ruby exigem o interpretador correspondente (`python3`/`python`, `ruby`) no `PATH` onde quer que `archgate check` seja executado -- em cada máquina de desenvolvedor **e** no CI. O parsing de TypeScript e JavaScript é embutido no Archgate e não precisa de interpretador.
:::

#### findAstNodes

```typescript
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):

```typescript
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:

```typescript
const tree = await ctx.ast("app/models.py", "python");
const funcDefs = ctx.findAstNodes(tree, "FunctionDef", "AsyncFunctionDef");
```

---

## RuleReport
Expand Down
45 changes: 45 additions & 0 deletions docs/src/content/docs/reference/rule-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ interface RuleContext {
fileAtBase(path: string): Promise<string | null>;
readJSON(path: string): Promise<unknown>;
ast(path: string, language: AstLanguage, opts?: AstOptions): Promise<AstNode>;
findAstNodes(tree: AstNode, ...types: string[]): AstNode[];
report: RuleReport;
}
```
Expand Down Expand Up @@ -288,6 +289,50 @@ A thrown error is isolated to the failing rule: other rules and ADRs in the same
Python and Ruby rules require the corresponding interpreter (`python3`/`python`, `ruby`) on `PATH` wherever `archgate check` runs -- on every developer machine **and** in CI. TypeScript and JavaScript parsing is built into Archgate and needs no interpreter.
:::

#### findAstNodes

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

Recursively collect every node in a parsed AST whose type-discriminant field matches one of `types`. This is the built-in replacement for the recursive walker AST rules used to hand-roll: `ctx.ast()` deliberately returns language-native shapes, but finding nodes by type name only depends on the discriminant field, so one helper covers all languages. Synchronous -- no `await` needed.

- **Language-agnostic**: each object node is checked against whichever discriminant field it carries -- `_type` (Python) or `type` (ESTree TypeScript/JavaScript).
- **Full traversal**: own-enumerable object values and arrays are recursed, and the `tree` argument itself is a match candidate.
- **Multi-type matching**: pass several names when one construct spans multiple node types -- the common case (`"FunctionDef"`/`"AsyncFunctionDef"`, sync/async variants).
- **Ruby**: `Ripper.sexp` nodes are plain arrays with no object discriminant field, so a Ruby tree is recursed but its array-shaped nodes never match -- walk Ripper output against its own grammar instead.

Before -- the collector each rule file had to repeat (rule files cannot import shared helper modules):

```typescript
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);
```

After:

```typescript
const tree = await ctx.ast("app/models.py", "python");
const funcDefs = ctx.findAstNodes(tree, "FunctionDef", "AsyncFunctionDef");
```

---

## RuleReport
Expand Down
64 changes: 63 additions & 1 deletion src/engine/ast-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import type { AstLanguage } from "../formats/rules";
import type { AstLanguage, EsTreeNode, PythonAstNode } from "../formats/rules";
import { logDebug } from "../helpers/log";
import { isWindows } from "../helpers/platform";
import { UserError } from "../helpers/user-error";
Expand Down Expand Up @@ -449,6 +449,68 @@ export function finalizeAstResult(
return tree;
}

/**
* `ctx.findAstNodes()`: collect every node in a parsed AST whose
* type-discriminant field matches one of `types`. Language-agnostic — each
* object node is checked against whichever discriminant field it carries:
* `_type` (Python) or `type` (ESTree TypeScript/JavaScript). Own-enumerable
* object values and arrays are traversed, and `tree` itself is a match
* candidate. Ruby's `Ripper.sexp` nodes are plain arrays with no object
* discriminant field, so a Ruby tree is traversed but its array-shaped nodes
* never match. The traversal is iterative (explicit stack, preorder) so a
* deeply nested tree cannot overflow the call stack, and a visited set
* guards against cycles — cheap insurance, real ASTs are acyclic.
*/
export function findAstNodes(
tree: EsTreeNode,
...types: string[]
): EsTreeNode[];
export function findAstNodes(
tree: PythonAstNode,
...types: string[]
): PythonAstNode[];
export function findAstNodes(
tree: unknown,
...types: string[]
): (EsTreeNode | PythonAstNode)[];
export function findAstNodes(
tree: unknown,
...types: string[]
): (EsTreeNode | PythonAstNode)[] {
const wanted = new Set(types);
const matches: (EsTreeNode | PythonAstNode)[] = [];
const visited = new WeakSet<object>();

// Explicit stack (LIFO) instead of recursion so deeply nested trees can't
// overflow the call stack. Children are pushed in reverse so they pop in
// original order, preserving preorder match ordering.
const pending: unknown[] = [tree];
while (pending.length > 0) {
const node = pending.pop();
if (!node || typeof node !== "object" || visited.has(node)) continue;
visited.add(node);
if (Array.isArray(node)) {
for (let i = node.length - 1; i >= 0; i--) pending.push(node[i]);
continue;
}
const record = node as Record<string, unknown>;
// Prefer `_type` (Python) — a Python node may also carry an unrelated
// `type` FIELD (e.g. ExceptHandler's exception type), never vice versa.
const discriminant =
typeof record._type === "string"
? record._type
: typeof record.type === "string"
? record.type
: undefined;
if (discriminant !== undefined && wanted.has(discriminant)) {
matches.push(record as EsTreeNode | PythonAstNode);
}
const values = Object.values(record);
for (let i = values.length - 1; i >= 0; i--) pending.push(values[i]);
}
return matches;
}

/**
* Cache key for a per-run `ctx.ast()` parse (see `RunCaches.astResults` in
* runner.ts): the full tuple that determines the parse output, NUL-joined —
Expand Down
7 changes: 5 additions & 2 deletions src/engine/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
RUBY_BASENAMES,
astCacheKey,
finalizeAstResult,
findAstNodes,
implausibleLanguageError,
interpreterCandidates,
interpreterNotFoundError,
Expand Down Expand Up @@ -423,12 +424,14 @@ function createRuleContext(
},

readJSON(path: string): Promise<any> {
const absPath = safePath(resolvedRoot, path);
return Bun.file(absPath).json();
return Bun.file(safePath(resolvedRoot, path)).json();
},

// ARCH-022: the only sanctioned path from rule code to language tooling.
ast: astImpl,
// Generic by-type-name AST node collector — a pure, synchronous
// traversal built in like glob/grep so rule files need not hand-roll it.
findAstNodes,
};
}

Expand Down
20 changes: 20 additions & 0 deletions src/formats/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,26 @@ export interface RuleContext {
opts?: AstOptions
): Promise<RubyAstProgram>;
ast(path: string, language: AstLanguage, opts?: AstOptions): Promise<AstNode>;
/**
* Recursively collect every node in a parsed AST whose type-discriminant
* field matches one of `types`. Language-agnostic: each object node is
* checked against whichever discriminant field it carries — `_type`
* (Python) or `type` (ESTree TypeScript/JavaScript). Own-enumerable object
* values and arrays are recursed, and `tree` itself is a match candidate.
*
* Ruby: `Ripper.sexp` nodes are plain arrays with no object discriminant
* field, so a Ruby tree is recursed but its array-shaped nodes never
* match — only object nodes carrying a matching `_type`/`type` are
* collected.
*
* Pure tree traversal — synchronous, no I/O.
*/
findAstNodes(tree: EsTreeNode, ...types: string[]): EsTreeNode[];
findAstNodes(tree: PythonAstNode, ...types: string[]): PythonAstNode[];
findAstNodes(
tree: unknown,
...types: string[]
): (EsTreeNode | PythonAstNode)[];
report: RuleReport;
}

Expand Down
16 changes: 16 additions & 0 deletions src/helpers/rules-shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,22 @@ declare interface RuleContext {
opts?: AstOptions
): Promise<RubyAstProgram>;
ast(path: string, language: AstLanguage, opts?: AstOptions): Promise<AstNode>;
/**
* Recursively collect every node in a parsed AST whose type-discriminant
* field matches one of \`types\`. Language-agnostic: each object node is
* checked against whichever discriminant field it carries — \`_type\`
* (Python) or \`type\` (ESTree TypeScript/JavaScript). Own-enumerable
* object values and arrays are recursed, and \`tree\` itself is a match
* candidate. Ruby's \`Ripper.sexp\` nodes are plain arrays with no object
* discriminant field, so a Ruby tree is recursed but its array-shaped nodes
* never match. Pure tree traversal — synchronous, no I/O.
*/
findAstNodes(tree: EsTreeNode, ...types: string[]): EsTreeNode[];
findAstNodes(tree: PythonAstNode, ...types: string[]): PythonAstNode[];
findAstNodes(
tree: unknown,
...types: string[]
): (EsTreeNode | PythonAstNode)[];
report: RuleReport;
}

Expand Down
Loading
Loading