From 8b384457be7b78681249b823e789629f9fcd256d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 16 Jul 2026 18:09:57 -0300 Subject: [PATCH 1/4] feat(engine): add ctx.findAstNodes() generic AST node collector (closes #483) Every .rules.ts file walking ctx.ast() output re-implemented the same recursive find-nodes-by-type walker, and the sandbox module allowlist means rule files cannot share a helper. Expose the collector as a built-in on RuleContext instead, implemented in the trusted engine (ast-support.ts) like ctx.glob. - Matches whichever discriminant a node carries: _type (Python) or type (ESTree TS/JS), preferring _type since Python nodes can carry an unrelated field named "type" (e.g. ExceptHandler) - Recurses own-enumerable object values and arrays; the root itself is a match candidate; WeakSet cycle guard as defensive insurance - Ruby: Ripper.sexp arrays are recursed, but array-shaped nodes have no object discriminant and never match (documented explicitly) - Overloaded signatures on RuleContext and in the generated rules.d.ts shim narrow the element type per tree shape - Docs updated in all three locales with a before/after example replacing the hand-rolled collectFunctionDefs walker Signed-off-by: Rhuan Barreto --- .../content/docs/nb/reference/rule-api.mdx | 45 ++++++ .../content/docs/pt-br/reference/rule-api.mdx | 45 ++++++ docs/src/content/docs/reference/rule-api.mdx | 45 ++++++ src/engine/ast-support.ts | 59 +++++++- src/engine/runner.ts | 5 + src/formats/rules.ts | 20 +++ src/helpers/rules-shim.ts | 16 +++ tests/engine/ast-support.test.ts | 103 ++++++++++++++ tests/engine/runner-find-ast-nodes.test.ts | 128 ++++++++++++++++++ 9 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 tests/engine/runner-find-ast-nodes.test.ts diff --git a/docs/src/content/docs/nb/reference/rule-api.mdx b/docs/src/content/docs/nb/reference/rule-api.mdx index 853d3cbf..9d1ccd93 100644 --- a/docs/src/content/docs/nb/reference/rule-api.mdx +++ b/docs/src/content/docs/nb/reference/rule-api.mdx @@ -67,6 +67,7 @@ interface RuleContext { fileAtBase(path: string): Promise; readJSON(path: string): Promise; ast(path: string, language: AstLanguage, opts?: AstOptions): Promise; + findAstNodes(tree: AstNode, ...types: string[]): AstNode[]; report: RuleReport; } ``` @@ -286,6 +287,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 diff --git a/docs/src/content/docs/pt-br/reference/rule-api.mdx b/docs/src/content/docs/pt-br/reference/rule-api.mdx index 72d1fcee..020d9c5d 100644 --- a/docs/src/content/docs/pt-br/reference/rule-api.mdx +++ b/docs/src/content/docs/pt-br/reference/rule-api.mdx @@ -67,6 +67,7 @@ interface RuleContext { fileAtBase(path: string): Promise; readJSON(path: string): Promise; ast(path: string, language: AstLanguage, opts?: AstOptions): Promise; + findAstNodes(tree: AstNode, ...types: string[]): AstNode[]; report: RuleReport; } ``` @@ -286,6 +287,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 diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx index fe0938c2..4a90700e 100644 --- a/docs/src/content/docs/reference/rule-api.mdx +++ b/docs/src/content/docs/reference/rule-api.mdx @@ -67,6 +67,7 @@ interface RuleContext { fileAtBase(path: string): Promise; readJSON(path: string): Promise; ast(path: string, language: AstLanguage, opts?: AstOptions): Promise; + findAstNodes(tree: AstNode, ...types: string[]): AstNode[]; report: RuleReport; } ``` @@ -286,6 +287,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 diff --git a/src/engine/ast-support.ts b/src/engine/ast-support.ts index 7a32a8ca..6833df33 100644 --- a/src/engine/ast-support.ts +++ b/src/engine/ast-support.ts @@ -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"; @@ -392,6 +392,63 @@ export function finalizeAstResult( return tree; } +/** + * `ctx.findAstNodes()`: 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. 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(); + + function visit(node: unknown): void { + if (!node || typeof node !== "object" || visited.has(node)) return; + visited.add(node); + if (Array.isArray(node)) { + for (const item of node) visit(item); + return; + } + const record = node as Record; + // 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); + } + for (const value of Object.values(record)) visit(value); + } + + visit(tree); + return matches; +} + /** Extract a readable message from Bun.Transpiler/meriyah parse errors. */ export function parseErrorMessage(err: unknown): string { if (err instanceof AggregateError && err.errors.length > 0) { diff --git a/src/engine/runner.ts b/src/engine/runner.ts index ff630bed..b7fe4ba1 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -24,6 +24,7 @@ import { RUBY_BASENAMES, commentsUnsupportedError, finalizeAstResult, + findAstNodes, implausibleLanguageError, interpreterCandidates, interpreterNotFoundError, @@ -395,6 +396,10 @@ function createRuleContext( // 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, }; } diff --git a/src/formats/rules.ts b/src/formats/rules.ts index b1261d27..a560721b 100644 --- a/src/formats/rules.ts +++ b/src/formats/rules.ts @@ -224,6 +224,26 @@ export interface RuleContext { ): Promise; ast(path: string, language: "ruby", opts?: AstOptions): Promise; ast(path: string, language: AstLanguage, opts?: AstOptions): Promise; + /** + * 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; } diff --git a/src/helpers/rules-shim.ts b/src/helpers/rules-shim.ts index 130c4448..6b9dd233 100644 --- a/src/helpers/rules-shim.ts +++ b/src/helpers/rules-shim.ts @@ -214,6 +214,22 @@ declare interface RuleContext { ): Promise; ast(path: string, language: "ruby", opts?: AstOptions): Promise; ast(path: string, language: AstLanguage, opts?: AstOptions): Promise; + /** + * 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; } diff --git a/tests/engine/ast-support.test.ts b/tests/engine/ast-support.test.ts index f0ab09b3..ff80a7d1 100644 --- a/tests/engine/ast-support.test.ts +++ b/tests/engine/ast-support.test.ts @@ -8,12 +8,14 @@ import { join } from "node:path"; import { PYTHON_AST_PROGRAM, RUBY_AST_PROGRAM, + findAstNodes, interpreterCandidates, parseAstJson, parseErrorMessage, probeInterpreter, runAstSubprocess, } from "../../src/engine/ast-support"; +import type { PythonAstNode } from "../../src/formats/rules"; import { isWindows } from "../../src/helpers/platform"; // Probe once at load time so interpreter-dependent tests can skipIf cleanly. @@ -293,3 +295,104 @@ describe("RUBY_AST_PROGRAM end-to-end", () => { } ); }); + +describe("findAstNodes", () => { + test("rewrites the hand-rolled collectFunctionDefs walker to a one-liner", () => { + // Python-shaped (_type discriminant) tree, as ctx.ast(path, "python") + // returns it. + const tree = { + _type: "Module", + body: [ + { _type: "FunctionDef", name: "top_level", body: [] }, + { + _type: "ClassDef", + name: "Service", + body: [ + { _type: "AsyncFunctionDef", name: "fetch", body: [] }, + { _type: "FunctionDef", name: "close", body: [] }, + ], + }, + ], + }; + + const hits = findAstNodes(tree, "FunctionDef", "AsyncFunctionDef"); + expect(hits.map((n) => n.name)).toEqual(["top_level", "fetch", "close"]); + }); + + test("matches ESTree-shaped nodes via their type discriminant", () => { + const program = { + type: "Program", + body: [ + { + type: "FunctionDeclaration", + id: { type: "Identifier", name: "hello" }, + params: [], + }, + { + type: "ExpressionStatement", + expression: { + type: "CallExpression", + callee: { type: "Identifier", name: "hello" }, + arguments: [], + }, + }, + ], + }; + + const identifiers = findAstNodes(program, "Identifier"); + expect(identifiers.map((n) => n.name)).toEqual(["hello", "hello"]); + expect(findAstNodes(program, "CallExpression")).toHaveLength(1); + }); + + test("the root node itself is a match candidate", () => { + const tree = { _type: "Module", body: [] }; + const hits = findAstNodes(tree, "Module"); + expect(hits).toHaveLength(1); + expect(hits[0]).toBe(tree); + }); + + test("recurses through nested arrays", () => { + const tree = { + _type: "Matrix", + rows: [ + [{ _type: "Cell", value: 1 }], + [[{ _type: "Cell", value: 2 }], { _type: "Cell", value: 3 }], + ], + }; + expect(findAstNodes(tree, "Cell").map((n) => n.value)).toEqual([1, 2, 3]); + }); + + test("ruby sexp arrays are recursed but array-shaped nodes never match", () => { + // Ripper.sexp carries its tag as element 0, not as an object field. + const sexp = ["program", [["command", [["@ident", "puts", [1, 0]]]]]]; + expect(findAstNodes(sexp, "program", "command", "@ident")).toEqual([]); + }); + + test("prefers _type over an unrelated string field named type", () => { + const handler = { _type: "ExceptHandler", type: "Name" }; + expect(findAstNodes(handler, "Name")).toEqual([]); + expect(findAstNodes(handler, "ExceptHandler")).toEqual([handler]); + }); + + test("cycle guard terminates on self-referential objects and arrays", () => { + const node: PythonAstNode = { _type: "Loop" }; + node.self = node; + const ring: unknown[] = [node]; + ring.push(ring); + node.items = ring; + + expect(findAstNodes(node, "Loop")).toEqual([node]); + }); + + test("a node reachable through two parents is collected once", () => { + const shared = { _type: "Name", id: "x" }; + const tree = { _type: "Module", left: shared, right: shared }; + expect(findAstNodes(tree, "Name")).toEqual([shared]); + }); + + test("returns empty for primitive and null roots", () => { + expect(findAstNodes(null, "Module")).toEqual([]); + expect(findAstNodes("Module", "Module")).toEqual([]); + expect(findAstNodes(42, "Module")).toEqual([]); + }); +}); diff --git a/tests/engine/runner-find-ast-nodes.test.ts b/tests/engine/runner-find-ast-nodes.test.ts new file mode 100644 index 00000000..909da188 --- /dev/null +++ b/tests/engine/runner-find-ast-nodes.test.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + interpreterCandidates, + probeInterpreter, +} from "../../src/engine/ast-support"; +import type { LoadResult } from "../../src/engine/loader"; +import { runChecks } from "../../src/engine/runner"; +import type { RuleSet } from "../../src/formats/rules"; + +// Probe once at load time so interpreter-dependent tests can skipIf cleanly. +const pythonInterpreter = await probeInterpreter( + interpreterCandidates("python") +); + +describe("runChecks ctx.findAstNodes()", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-runner-find-")); + mkdirSync(join(tempDir, "src"), { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function makeLoadedAdr(ruleSet: RuleSet): LoadResult { + return { + type: "loaded", + value: { + adr: { + frontmatter: { + id: "FIND-001", + title: "findAstNodes Test", + domain: "general", + rules: true, + }, + body: "", + filePath: "/test.md", + }, + ruleSet, + }, + }; + } + + test("typescript: replaces a hand-rolled walker with a one-liner", async () => { + writeFileSync( + join(tempDir, "src", "calls.ts"), + [ + "export function outer(): void {", + " inner();", + "}", + "", + "function inner(): void {}", + "", + ].join("\n") + ); + + let fnNames: unknown[] = []; + let callCount = 0; + + const loaded = makeLoadedAdr({ + rules: { + "collect-declarations": { + description: "Collect nodes without a hand-rolled recursive walker", + async check(ctx) { + const program = await ctx.ast("src/calls.ts", "typescript"); + const fns = ctx.findAstNodes(program, "FunctionDeclaration"); + fnNames = fns.map( + (n) => (n.id as { name?: string } | undefined)?.name + ); + callCount = ctx.findAstNodes(program, "CallExpression").length; + }, + }, + }, + }); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toBeUndefined(); + // The export-wrapped declaration is found too — the collector recurses + // through every own-enumerable value, not just Program.body. + expect(fnNames).toEqual(["outer", "inner"]); + expect(callCount).toBe(1); + }); + + test.skipIf(!pythonInterpreter)( + "python: multi-type match over real ast output", + async () => { + writeFileSync( + join(tempDir, "src", "svc.py"), + [ + "def sync_fn():", + " pass", + "", + "async def async_fn():", + " pass", + "", + ].join("\n") + ); + + let names: unknown[] = []; + + const loaded = makeLoadedAdr({ + rules: { + "collect-defs": { + description: "One-liner replacement for collectFunctionDefs", + async check(ctx) { + const tree = await ctx.ast("src/svc.py", "python"); + names = ctx + .findAstNodes(tree, "FunctionDef", "AsyncFunctionDef") + .map((n) => n.name); + }, + }, + }, + }); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toBeUndefined(); + expect(names).toEqual(["sync_fn", "async_fn"]); + } + ); +}); From bfee55068f4cf320f224086d5ce47ea7fa336fb1 Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:11:06 +0000 Subject: [PATCH 2/4] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/public/llms-full.txt | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 43046691..dc701161 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -5221,6 +5221,7 @@ interface RuleContext { fileAtBase(path: string): Promise; readJSON(path: string): Promise; ast(path: string, language: AstLanguage, opts?: AstOptions): Promise; + findAstNodes(tree: AstNode, ...types: string[]): AstNode[]; report: RuleReport; } ``` @@ -5439,6 +5440,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 From f5250251477fca1726b62530ab21acd814964959 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 16 Jul 2026 23:49:38 -0300 Subject: [PATCH 3/4] fix(engine): make findAstNodes traversal iterative to avoid stack overflow A deeply nested tree could exhaust the JS call stack in the recursive walker, turning a valid rule into an exit-code-2 execution error. Replace recursion with an explicit LIFO stack; children are pushed in reverse so preorder match ordering, the WeakSet cycle guard, and the _type-before-type discriminant preference are all preserved. Add a regression test collecting a leaf from a ~100k-deep tree. Signed-off-by: Rhuan Barreto --- src/engine/ast-support.ts | 29 +++++++++++++++++------------ tests/engine/ast-support.test.ts | 13 +++++++++++++ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/engine/ast-support.ts b/src/engine/ast-support.ts index 6833df33..3e38dc50 100644 --- a/src/engine/ast-support.ts +++ b/src/engine/ast-support.ts @@ -393,15 +393,16 @@ export function finalizeAstResult( } /** - * `ctx.findAstNodes()`: recursively collect every node in a parsed AST whose + * `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 recursed, and `tree` itself is a match + * 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 recursed but its array-shaped nodes - * never match. A visited set guards against cycles — cheap insurance, real - * ASTs are acyclic. + * 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, @@ -423,12 +424,17 @@ export function findAstNodes( const matches: (EsTreeNode | PythonAstNode)[] = []; const visited = new WeakSet(); - function visit(node: unknown): void { - if (!node || typeof node !== "object" || visited.has(node)) return; + // 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 (const item of node) visit(item); - return; + for (let i = node.length - 1; i >= 0; i--) pending.push(node[i]); + continue; } const record = node as Record; // Prefer `_type` (Python) — a Python node may also carry an unrelated @@ -442,10 +448,9 @@ export function findAstNodes( if (discriminant !== undefined && wanted.has(discriminant)) { matches.push(record as EsTreeNode | PythonAstNode); } - for (const value of Object.values(record)) visit(value); + const values = Object.values(record); + for (let i = values.length - 1; i >= 0; i--) pending.push(values[i]); } - - visit(tree); return matches; } diff --git a/tests/engine/ast-support.test.ts b/tests/engine/ast-support.test.ts index ff80a7d1..b29ab6dc 100644 --- a/tests/engine/ast-support.test.ts +++ b/tests/engine/ast-support.test.ts @@ -390,6 +390,19 @@ describe("findAstNodes", () => { expect(findAstNodes(tree, "Name")).toEqual([shared]); }); + test("a deeply nested tree does not overflow the call stack", () => { + // ~100k levels deep — far beyond the JS call-stack limit a recursive + // walker would hit. Built leaf-up so the leaf sits at maximum depth. + let node: PythonAstNode = { _type: "Leaf", value: "bottom" }; + for (let i = 0; i < 100_000; i++) { + node = { _type: "Wrapper", body: [node] }; + } + + const hits = findAstNodes(node, "Leaf"); + expect(hits).toHaveLength(1); + expect(hits[0].value).toBe("bottom"); + }); + test("returns empty for primitive and null roots", () => { expect(findAstNodes(null, "Module")).toEqual([]); expect(findAstNodes("Module", "Module")).toEqual([]); From d916c1807eb2fc5dea644171591af131461c0830 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 16 Jul 2026 23:49:38 -0300 Subject: [PATCH 4/4] refactor(test): use Bun.write for runner-find-ast-nodes fixtures Both test callbacks are async, so fixture writes can use the Bun file-I/O built-in instead of node:fs writeFileSync, per the repo guideline to prefer Bun built-ins. Signed-off-by: Rhuan Barreto --- tests/engine/runner-find-ast-nodes.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/engine/runner-find-ast-nodes.test.ts b/tests/engine/runner-find-ast-nodes.test.ts index 909da188..0f733da1 100644 --- a/tests/engine/runner-find-ast-nodes.test.ts +++ b/tests/engine/runner-find-ast-nodes.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -50,7 +50,7 @@ describe("runChecks ctx.findAstNodes()", () => { } test("typescript: replaces a hand-rolled walker with a one-liner", async () => { - writeFileSync( + await Bun.write( join(tempDir, "src", "calls.ts"), [ "export function outer(): void {", @@ -92,7 +92,7 @@ describe("runChecks ctx.findAstNodes()", () => { test.skipIf(!pythonInterpreter)( "python: multi-type match over real ast output", async () => { - writeFileSync( + await Bun.write( join(tempDir, "src", "svc.py"), [ "def sync_fn():",