diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 4922e145..d62ba8b0 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; } ``` @@ -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 diff --git a/docs/src/content/docs/nb/reference/rule-api.mdx b/docs/src/content/docs/nb/reference/rule-api.mdx index 86ad7210..182a89db 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; } ``` @@ -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 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 4aac36b7..ba6206c0 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; } ``` @@ -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 diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx index 9718afba..d1365acf 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; } ``` @@ -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 diff --git a/src/engine/ast-support.ts b/src/engine/ast-support.ts index a3413df1..db5be83d 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"; @@ -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(); + + // 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; + // 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 — diff --git a/src/engine/runner.ts b/src/engine/runner.ts index 79d3979d..cccafa1a 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -26,6 +26,7 @@ import { RUBY_BASENAMES, astCacheKey, finalizeAstResult, + findAstNodes, implausibleLanguageError, interpreterCandidates, interpreterNotFoundError, @@ -423,12 +424,14 @@ function createRuleContext( }, readJSON(path: string): Promise { - 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, }; } diff --git a/src/formats/rules.ts b/src/formats/rules.ts index f2ac918f..1d7ca4e2 100644 --- a/src/formats/rules.ts +++ b/src/formats/rules.ts @@ -245,6 +245,26 @@ export interface RuleContext { 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 010657c1..3c0c1ca7 100644 --- a/src/helpers/rules-shim.ts +++ b/src/helpers/rules-shim.ts @@ -233,6 +233,22 @@ declare interface RuleContext { 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/find-ast-nodes.test.ts b/tests/engine/find-ast-nodes.test.ts new file mode 100644 index 00000000..f894922a --- /dev/null +++ b/tests/engine/find-ast-nodes.test.ts @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test } from "bun:test"; + +import { findAstNodes } from "../../src/engine/ast-support"; +import type { PythonAstNode } from "../../src/formats/rules"; + +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("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([]); + 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..0f733da1 --- /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 } 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 () => { + await Bun.write( + 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 () => { + await Bun.write( + 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"]); + } + ); +});