From 448c77b19c39d15c62c64fdea24dd13f0c551559 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 4 Jul 2026 22:42:53 -0300 Subject: [PATCH 01/16] feat(engine): add AST-aware rule context ctx.ast() per ARCH-022 RuleContext gains a single ast(path, language) method supporting typescript/javascript (in-process via the shared meriyah parser) and python/ruby (stdlib ast module / Ripper via Bun.spawn subprocesses). Guardrail ordering per ARCH-022: safePath sandbox, language plausibility, cached interpreter probe, array-args-only invocation. Throws (never null) on parse failure or missing interpreter, with distinguishable messages riding the existing per-rule error isolation. The duplicated parseModule() calls in rule-scanner.ts are factored into the shared js-parser.ts helper as mandated by the ADR. Tests for the new modules land in the follow-up commit in this PR (ARCH-005 flags them until then). Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- src/engine/ast-support.ts | 191 +++++++++++++++++++++++++++++++++++++ src/engine/js-parser.ts | 28 ++++++ src/engine/rule-scanner.ts | 10 +- src/engine/runner.ts | 103 +++++++++++++++++++- src/formats/rules.ts | 31 ++++++ src/helpers/rules-shim.ts | 27 ++++++ 6 files changed, 383 insertions(+), 7 deletions(-) create mode 100644 src/engine/ast-support.ts create mode 100644 src/engine/js-parser.ts diff --git a/src/engine/ast-support.ts b/src/engine/ast-support.ts new file mode 100644 index 00000000..a1f466c6 --- /dev/null +++ b/src/engine/ast-support.ts @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import type { AstLanguage } from "../formats/rules"; +import { logDebug } from "../helpers/log"; +import { isWindows } from "../helpers/platform"; + +/** + * Support code for `ctx.ast()` (ARCH-022). Definitions live here to keep + * `runner.ts` focused; the mandated four-step guardrail ordering (path + * safety → language plausibility → interpreter probe → guarded invocation) + * is sequenced inside `createRuleContext()` in `runner.ts`, which is the + * only caller of these helpers. + */ + +/** Hard cap on a single AST parser subprocess, well under the rule timeout. */ +export const AST_SUBPROCESS_TIMEOUT_MS = 15_000; + +/** + * Guardrail 2 (language plausibility): extensions accepted per language. + * Checked before any interpreter is invoked on the file. + */ +export const AST_LANGUAGE_EXTENSIONS: Record = { + typescript: [".ts", ".tsx", ".mts", ".cts"], + javascript: [".js", ".jsx", ".mjs", ".cjs"], + python: [".py", ".pyi"], + ruby: [".rb", ".rake", ".gemspec"], +}; + +/** Extensionless file basenames accepted as Ruby (Rakefile, Gemfile). */ +export const RUBY_BASENAMES = new Set(["rakefile", "gemfile"]); + +/** + * Serializer passed to ` -c`. Reads the target file from argv (never + * interpolated into the program), parses it with the standard `ast` module, + * and prints the tree as JSON. Non-finite floats, bytes, and complex numbers + * fall back to `repr()` so the output is always strict JSON. + */ +export const PYTHON_AST_PROGRAM = ` +import ast, json, sys + +sys.setrecursionlimit(10000) + +def convert(node): + if isinstance(node, ast.AST): + out = {"_type": type(node).__name__} + for name, value in ast.iter_fields(node): + out[name] = convert(value) + for attr in node._attributes: + if hasattr(node, attr): + out[attr] = convert(getattr(node, attr)) + return out + if isinstance(node, list): + return [convert(item) for item in node] + if isinstance(node, float) and (node != node or node in (float("inf"), float("-inf"))): + return repr(node) + if isinstance(node, (str, int, float, bool)) or node is None: + return node + return repr(node) + +with open(sys.argv[1], encoding="utf-8") as handle: + source = handle.read() +try: + tree = ast.parse(source, filename=sys.argv[1]) +except SyntaxError as exc: + print(f"{exc.msg} (line {exc.lineno}, column {exc.offset})", file=sys.stderr) + sys.exit(1) +print(json.dumps(convert(tree))) +`; + +/** + * Serializer passed to `ruby -rripper -rjson -e`. `Ripper.sexp` returns nil + * on syntax errors (it never raises), so nil is mapped to a non-zero exit. + * `max_nesting: false` because real-world ASTs exceed JSON's default depth. + */ +export const RUBY_AST_PROGRAM = ` +source = File.read(ARGV[0], encoding: "UTF-8") +sexp = Ripper.sexp(source) +if sexp.nil? + warn "Ruby syntax error" + exit 1 +end +puts JSON.generate(sexp, max_nesting: false) +`; + +/** + * Candidate executable names per language, in probe order. `python3` is not + * a universal PATH alias on Windows (the common installer exposes `python`), + * so the order flips per platform (ARCH-009's isWindows()). + */ +export function interpreterCandidates(language: "python" | "ruby"): string[] { + if (language === "ruby") return ["ruby"]; + return isWindows() ? ["python", "python3"] : ["python3", "python"]; +} + +/** + * Guardrail 3 (interpreter availability probe): spawn ` --version` + * and use the first candidate that exits 0. A plain `Bun.which()` lookup is + * not enough on Windows — the Microsoft Store ships a `python.exe` App + * Execution Alias stub that exists on PATH but exits non-zero. + * + * Callers cache the returned promise once per `check` invocation. + */ +export async function probeInterpreter( + candidates: string[] +): Promise { + for (const candidate of candidates) { + try { + const proc = Bun.spawn([candidate, "--version"], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + // oxlint-disable-next-line no-await-in-loop -- candidates probed in priority order + const [exitCode, version] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + ]); + if (exitCode === 0) { + logDebug( + `ctx.ast interpreter probe: ${candidate} -> ${version.trim()}` + ); + return candidate; + } + } catch { + // Executable not found — try the next candidate. + } + } + return null; +} + +/** + * Guardrail 4 (guarded invocation): run an AST parser subprocess with + * array-based arguments only (ARCH-007 — no shell interpolation of paths or + * file contents), draining stdout/stderr concurrently with the exit wait so + * large AST output cannot deadlock the pipe buffer. + */ +export async function runAstSubprocess( + cmd: string[] +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const proc = Bun.spawn(cmd, { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const stdoutPromise = new Response(proc.stdout).text(); + const stderrPromise = new Response(proc.stderr).text(); + + let timer: ReturnType | undefined; + const timeout = new Promise<"timeout">((resolve) => { + timer = setTimeout(() => resolve("timeout"), AST_SUBPROCESS_TIMEOUT_MS); + }); + const result = await Promise.race([proc.exited, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }); + if (result === "timeout") { + proc.kill(); + throw new Error( + `AST parser subprocess timed out after ${AST_SUBPROCESS_TIMEOUT_MS}ms` + ); + } + + const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); + return { exitCode: result, stdout, stderr }; +} + +/** + * Parse an AST subprocess's stdout as JSON, mapping malformed output to the + * same throw contract as any other `ctx.ast()` failure. Subprocess stdout is + * not a file read, so `Bun.file().json()` (ARCH-010) does not apply here. + */ +export function parseAstJson( + stdout: string, + path: string, + language: string +): Record | unknown[] { + try { + return JSON.parse(stdout) as Record | unknown[]; + } catch { + throw new Error( + `Failed to parse "${path}" as ${language}: interpreter produced invalid JSON output` + ); + } +} + +/** Extract a readable message from Bun.Transpiler/meriyah parse errors. */ +export function parseErrorMessage(err: unknown): string { + if (err instanceof AggregateError && err.errors.length > 0) { + return String(err.errors[0]); + } + return err instanceof Error ? err.message : String(err); +} diff --git a/src/engine/js-parser.ts b/src/engine/js-parser.ts new file mode 100644 index 00000000..b663d053 --- /dev/null +++ b/src/engine/js-parser.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { parseModule } from "meriyah"; + +/** ESTree Program produced by meriyah's `parseModule`. */ +export type EsTreeProgram = ReturnType; + +/** + * Parse JavaScript module source into an ESTree AST via meriyah. + * + * This is the single sanctioned `parseModule()` call site, shared by the + * rule-file sandbox scanner (`rule-scanner.ts`) and the `ctx.ast()` + * TypeScript/JavaScript branch in `runner.ts` — per ARCH-022, the parse + * call must not be duplicated inline at each consumer. + * + * Throws on syntax errors; callers decide how to surface them. + */ +export function parseJsModule( + source: string, + options?: { jsx?: boolean } +): EsTreeProgram { + return parseModule(source, { + next: true, + loc: true, + module: true, + ...(options?.jsx ? { jsx: true } : {}), + }); +} diff --git a/src/engine/rule-scanner.ts b/src/engine/rule-scanner.ts index 7183556b..89deccee 100644 --- a/src/engine/rule-scanner.ts +++ b/src/engine/rule-scanner.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { parseModule } from "meriyah"; +import { parseJsModule, type EsTreeProgram } from "./js-parser"; /** * Banned module pattern — matches dangerous Node.js/Bun built-in modules @@ -70,9 +70,9 @@ export function scanRuleSource(source: string): ScanViolation[] { ]; } - let ast: ReturnType; + let ast: EsTreeProgram; try { - ast = parseModule(js, { next: true, loc: true, module: true }); + ast = parseJsModule(js); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return [ @@ -262,9 +262,9 @@ export function scanImportedRuleSource(source: string): ScanViolation[] { ]; } - let ast: ReturnType; + let ast: EsTreeProgram; try { - ast = parseModule(js, { next: true, loc: true, module: true }); + ast = parseJsModule(js); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return [ diff --git a/src/engine/runner.ts b/src/engine/runner.ts index e747f5df..dccc0516 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -4,6 +4,8 @@ import { lstatSync } from "node:fs"; import { relative, resolve, isAbsolute } from "node:path"; import type { + AstLanguage, + AstNode, GrepMatch, RuleContext, RuleReport, @@ -11,12 +13,24 @@ import type { } from "../formats/rules"; import { logDebug } from "../helpers/log"; import { UserError } from "../helpers/user-error"; +import { + AST_LANGUAGE_EXTENSIONS, + PYTHON_AST_PROGRAM, + RUBY_AST_PROGRAM, + RUBY_BASENAMES, + interpreterCandidates, + parseAstJson, + parseErrorMessage, + probeInterpreter, + runAstSubprocess, +} from "./ast-support"; import { resolveScopedFiles, getStagedFiles, getFilesChangedSinceRef, getGitTrackedFiles, } from "./git-files"; +import { parseJsModule } from "./js-parser"; import { type LoadResult, blockedToRuleResult } from "./loader"; import { applySuppressions, type SuppressionWarning } from "./suppressions"; @@ -132,7 +146,8 @@ function createRuleContext( adrId: string, ruleId: string, violations: ViolationDetail[], - trackedFiles: Set | null + trackedFiles: Set | null, + interpreterCache: Map> ): RuleContext { const report: RuleReport = { violation(detail) { @@ -267,6 +282,85 @@ function createRuleContext( const absPath = safePath(projectRoot, path); return Bun.file(absPath).json(); }, + + // ARCH-022: the only sanctioned path from rule code to language tooling. + // The four guardrails below MUST run in this order before any subprocess. + async ast(path: string, language: AstLanguage): Promise { + // Guardrail 1: path safety — same sandbox as readFile/glob. + const absPath = safePath(projectRoot, path); + + // Guardrail 2: language plausibility — refuse to hand a file to an + // interpreter unless its name plausibly matches the requested language. + const lowerPath = path.toLowerCase(); + const basename = lowerPath.split(/[/\\]/u).pop() ?? ""; + const plausible = + AST_LANGUAGE_EXTENSIONS[language].some((ext) => + lowerPath.endsWith(ext) + ) || + (language === "ruby" && RUBY_BASENAMES.has(basename)); + if (!plausible) { + throw new UserError( + `File "${path}" does not look like ${language} (expected ${AST_LANGUAGE_EXTENSIONS[ + language + ].join(", ")}) — refusing to parse` + ); + } + + // In-process branch: TypeScript/JavaScript via the shared meriyah + // parser (js-parser.ts). No subprocess is spawned for these languages. + if (language === "typescript" || language === "javascript") { + const source = await Bun.file(absPath).text(); + try { + if (language === "typescript") { + const loader = lowerPath.endsWith(".tsx") ? "tsx" : "ts"; + const js = new Bun.Transpiler({ loader }).transformSync(source); + return parseJsModule(js) as unknown as AstNode; + } + return parseJsModule(source, { + jsx: lowerPath.endsWith(".jsx"), + }) as unknown as AstNode; + } catch (err) { + throw new Error( + `Failed to parse "${path}" as ${language}: ${parseErrorMessage(err)}` + ); + } + } + + // Guardrail 3: interpreter availability probe, cached per check run. + const candidates = interpreterCandidates(language); + let probe = interpreterCache.get(language); + if (!probe) { + probe = probeInterpreter(candidates); + interpreterCache.set(language, probe); + } + const interpreter = await probe; + if (!interpreter) { + throw new Error( + `${language === "python" ? "Python" : "Ruby"} interpreter not found on PATH (tried: ${candidates.join( + ", " + )}) — ctx.ast("${path}", "${language}") requires it wherever \`archgate check\` runs` + ); + } + + // Guardrail 4: guarded invocation — array args only, path via argv. + const cmd = + language === "python" + ? [interpreter, "-c", PYTHON_AST_PROGRAM, absPath] + : [ + interpreter, + "-rripper", + "-rjson", + "-e", + RUBY_AST_PROGRAM, + absPath, + ]; + const { exitCode, stdout, stderr } = await runAstSubprocess(cmd); + if (exitCode !== 0) { + const detail = stderr.trim() || `exit code ${exitCode}`; + throw new Error(`Failed to parse "${path}" as ${language}: ${detail}`); + } + return parseAstJson(stdout, path, language); + }, }; } @@ -316,6 +410,10 @@ export async function runChecks( allTrackedFilesPromise, ]); + // ARCH-022: the ctx.ast() interpreter probe is cached once per check + // invocation — shared across every ADR and rule in this run. + const interpreterCache = new Map>(); + // Run ADRs in parallel const adrResults = await Promise.allSettled( loadedAdrs.map(async ({ adr, ruleSet }) => { @@ -352,7 +450,8 @@ export async function runChecks( adr.frontmatter.id, ruleId, violations, - trackedFiles + trackedFiles, + interpreterCache ); try { diff --git a/src/formats/rules.ts b/src/formats/rules.ts index 45cc26bc..d0fdbfb7 100644 --- a/src/formats/rules.ts +++ b/src/formats/rules.ts @@ -62,6 +62,25 @@ export interface PackageJson { [key: string]: unknown; } +// --- AST --- + +/** Languages supported by `RuleContext.ast()`. */ +export type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; + +/** + * Root node returned by `RuleContext.ast()`. + * + * The shape is language-native and deliberately NOT unified across languages + * (see ARCH-022): + * - `"typescript"` / `"javascript"` — ESTree Program (meriyah). TypeScript is + * transpiled before parsing, so type-only syntax is erased from the tree. + * - `"python"` — the standard `ast` module tree serialized to JSON; each node + * is `{ _type: "Name", ...fields, lineno, col_offset, ... }`. + * - `"ruby"` — `Ripper.sexp` output: nested arrays such as + * `["program", [["command", ...]]]` with `[line, column]` position pairs. + */ +export type AstNode = Record | unknown[]; + // --- Rule Context --- export interface RuleContext { @@ -74,6 +93,18 @@ export interface RuleContext { readFile(path: string): Promise; readJSON(path: "package.json"): Promise; readJSON(path: string): Promise; + /** + * Parse a source file into its language-native AST. + * + * TypeScript/JavaScript parse in-process. Python and Ruby require the + * corresponding interpreter (`python3`/`python`, `ruby`) on PATH wherever + * `archgate check` runs — locally and in CI. + * + * Throws (never returns null) when the file fails to parse or the required + * interpreter is missing; the error message distinguishes the two cases. + * The returned node shape differs per language — see {@link AstNode}. + */ + ast(path: string, language: AstLanguage): Promise; report: RuleReport; } diff --git a/src/helpers/rules-shim.ts b/src/helpers/rules-shim.ts index 982b8446..bd3137fd 100644 --- a/src/helpers/rules-shim.ts +++ b/src/helpers/rules-shim.ts @@ -69,6 +69,21 @@ declare interface PackageJson { [key: string]: unknown; } +/** Languages supported by \`RuleContext.ast()\`. */ +declare type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; + +/** + * Root node returned by \`RuleContext.ast()\`. The shape is language-native + * and deliberately NOT unified across languages: + * - "typescript" / "javascript" — ESTree Program (meriyah). TypeScript is + * transpiled before parsing, so type-only syntax is erased from the tree. + * - "python" — the standard \`ast\` module tree serialized to JSON; each node + * is \`{ _type: "Name", ...fields, lineno, col_offset, ... }\`. + * - "ruby" — \`Ripper.sexp\` output: nested arrays such as + * \`["program", [["command", ...]]]\` with \`[line, column]\` position pairs. + */ +declare type AstNode = Record | unknown[]; + declare interface RuleContext { projectRoot: string; scopedFiles: string[]; @@ -79,6 +94,18 @@ declare interface RuleContext { readFile(path: string): Promise; readJSON(path: "package.json"): Promise; readJSON(path: string): Promise; + /** + * Parse a source file into its language-native AST. + * + * TypeScript/JavaScript parse in-process. Python and Ruby require the + * corresponding interpreter (\`python3\`/\`python\`, \`ruby\`) on PATH + * wherever \`archgate check\` runs — locally and in CI. + * + * Throws (never returns null) when the file fails to parse or the required + * interpreter is missing; the error message distinguishes the two cases. + * The returned node shape differs per language — see AstNode. + */ + ast(path: string, language: AstLanguage): Promise; report: RuleReport; } From 587e2df87d7091a4d692423120f572aea8d6dcca Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 4 Jul 2026 22:44:53 -0300 Subject: [PATCH 02/16] feat(adr): enforce ARCH-022 with companion rules, dogfooding ctx.ast() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips ARCH-022 to rules:true as its own Compliance section mandates once ctx.ast() ships. The guardrail-ordering rule parses runner.ts via ctx.ast() itself — the first structural (non-regex) rule in the repo — plus subprocess-containment and single-method checks. Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- .../adrs/ARCH-022-ast-aware-rule-context.md | 12 +- .../ARCH-022-ast-aware-rule-context.rules.ts | 184 ++++++++++++++++++ 2 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 .archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts diff --git a/.archgate/adrs/ARCH-022-ast-aware-rule-context.md b/.archgate/adrs/ARCH-022-ast-aware-rule-context.md index 593a4716..f9a98f54 100644 --- a/.archgate/adrs/ARCH-022-ast-aware-rule-context.md +++ b/.archgate/adrs/ARCH-022-ast-aware-rule-context.md @@ -2,7 +2,11 @@ id: ARCH-022 title: AST-Aware Rule Context domain: architecture -rules: false +rules: true +files: + - "src/engine/**" + - "src/formats/rules.ts" + - "src/helpers/rules-shim.ts" --- ## Context @@ -99,7 +103,11 @@ This method dispatches internally based on `language`, and the dispatch mechanis ### Automated Enforcement -- None at this time. `rules: false` — this ADR documents an engine/API design decision made ahead of implementation. Once `ctx.ast()` ships, a follow-up amendment to this ADR (or a new companion ADR) MUST introduce `rules: true` with an automated check that flags any direct `Bun.spawn`/`child_process` usage inside `src/engine/runner.ts`'s `createRuleContext()` implementation that bypasses the mandated guardrail ordering, mirroring how `ARCH-007/no-bun-shell` scans for banned subprocess patterns today. +`ctx.ast()` has shipped, and this ADR now carries `rules: true` with three companion checks in `ARCH-022-ast-aware-rule-context.rules.ts`: + +- **`ast-guardrail-ordering`** — parses `src/engine/runner.ts` via `ctx.ast()` itself (dogfooding the capability this ADR introduces) and verifies the `ast()` method inside `createRuleContext()` invokes the four guardrail markers — `safePath`, `AST_LANGUAGE_EXTENSIONS`, `probeInterpreter`, `runAstSubprocess` — each present and in exactly that order. +- **`no-unsanctioned-engine-subprocess`** — flags any `Bun.spawn`/`Bun.spawnSync` call in `src/engine/` outside the sanctioned helpers (`ast-support.ts` for `ctx.ast()`, `git-files.ts` for git), and bans `child_process` imports in the engine entirely, mirroring how `ARCH-007/no-bun-shell` scans for banned subprocess patterns. +- **`single-ast-method`** — verifies `RuleContext` (in `src/formats/rules.ts` and the generated shim in `src/helpers/rules-shim.ts`) declares exactly one `ast(path, language)` signature and no per-language variants (`pythonAst()`, `rubyAst()`, etc.). ### Manual Enforcement diff --git a/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts b/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts new file mode 100644 index 00000000..5b0e169a --- /dev/null +++ b/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts @@ -0,0 +1,184 @@ +/// + +/** + * Identifiers that must appear, in this order, inside the `ast()` method of + * `createRuleContext()` (src/engine/runner.ts). Each anchors one of the four + * mandated guardrails: + * 1. safePath — path sandbox (same as readFile/glob) + * 2. AST_LANGUAGE_EXTENSIONS — language plausibility check + * 3. probeInterpreter — interpreter availability probe + * 4. runAstSubprocess — guarded array-args invocation + */ +const GUARDRAIL_SEQUENCE = [ + "safePath", + "AST_LANGUAGE_EXTENSIONS", + "probeInterpreter", + "runAstSubprocess", +]; + +/** Engine files sanctioned to call Bun.spawn (see ARCH-022 / ARCH-007). */ +const SANCTIONED_SPAWN_FILES = new Set([ + "src/engine/ast-support.ts", // ctx.ast() interpreter probe + guarded invocation + "src/engine/git-files.ts", // git subprocess helper, predates ARCH-022 +]); + +interface EsNode { + type?: string; + loc?: { start: { line: number; column: number } }; + [key: string]: unknown; +} + +/** Depth-first walk over an ESTree-shaped tree. */ +function walk(node: unknown, visit: (n: EsNode) => void): void { + if (Array.isArray(node)) { + for (const item of node) walk(item, visit); + return; + } + if (!node || typeof node !== "object") return; + const n = node as EsNode; + if (typeof n.type === "string") visit(n); + for (const value of Object.values(n)) { + if (value && typeof value === "object") walk(value, visit); + } +} + +export default { + rules: { + "ast-guardrail-ordering": { + description: + "createRuleContext()'s ast() method must run the four ARCH-022 guardrails in order: path safety, language plausibility, interpreter probe, guarded invocation", + severity: "error", + async check(ctx) { + const file = "src/engine/runner.ts"; + // Dogfood: this rule uses ctx.ast() itself to inspect the method that + // implements ctx.ast(), instead of a regex over raw source. + const tree = await ctx.ast(file, "typescript"); + + let astMethodBody: unknown = null; + walk(tree, (n) => { + if (n.type !== "Property") return; + const key = n.key as EsNode | undefined; + const value = n.value as EsNode | undefined; + if ( + key?.type === "Identifier" && + (key as { name?: string }).name === "ast" && + value?.type === "FunctionExpression" + ) { + astMethodBody = value.body; + } + }); + + if (!astMethodBody) { + ctx.report.violation({ + message: + "Could not locate the ast() method inside createRuleContext() — ARCH-022 requires RuleContext to expose exactly this method", + file, + fix: "Restore the ast(path, language) method on the object returned by createRuleContext()", + }); + return; + } + + // Record the first occurrence position of each guardrail identifier. + const firstSeen = new Map(); + walk(astMethodBody, (n) => { + if (n.type !== "Identifier" || !n.loc) return; + const name = (n as { name?: string }).name ?? ""; + if (!GUARDRAIL_SEQUENCE.includes(name) || firstSeen.has(name)) { + return; + } + firstSeen.set( + name, + n.loc.start.line * 1_000_000 + n.loc.start.column + ); + }); + + let previous = -1; + for (const identifier of GUARDRAIL_SEQUENCE) { + const position = firstSeen.get(identifier); + if (position === undefined) { + ctx.report.violation({ + message: `Guardrail marker "${identifier}" is missing from the ast() method — the four-step ARCH-022 ordering must be implemented in full`, + file, + fix: "Re-add the missing guardrail step to ast() in createRuleContext()", + }); + return; + } + if (position <= previous) { + ctx.report.violation({ + message: `Guardrail "${identifier}" runs out of order in the ast() method — ARCH-022 mandates path safety, then language plausibility, then interpreter probe, then guarded invocation`, + file, + fix: "Reorder ast() so each guardrail executes before the next one", + }); + return; + } + previous = position; + } + }, + }, + "no-unsanctioned-engine-subprocess": { + description: + "Bun.spawn in src/engine/ is confined to ast-support.ts and git-files.ts; child_process is banned entirely", + severity: "error", + async check(ctx) { + const spawnMatches = await ctx.grepFiles( + /Bun\.spawn(Sync)?\s*\(/u, + "src/engine/**/*.ts" + ); + for (const m of spawnMatches) { + if (SANCTIONED_SPAWN_FILES.has(m.file)) continue; + ctx.report.violation({ + message: `Unsanctioned subprocess call in ${m.file} — ARCH-022 confines engine Bun.spawn usage to ${[...SANCTIONED_SPAWN_FILES].join(", ")}`, + file: m.file, + line: m.line, + fix: "Route subprocess execution through the sanctioned helpers in ast-support.ts (ctx.ast) or git-files.ts (git)", + }); + } + + const importMatches = await ctx.grepFiles( + /from\s+["'](node:)?child_process["']|require\(\s*["'](node:)?child_process["']\s*\)/u, + "src/engine/**/*.ts" + ); + for (const m of importMatches) { + ctx.report.violation({ + message: `child_process import in ${m.file} — banned in the engine; use Bun.spawn via a sanctioned helper (ARCH-007/ARCH-022)`, + file: m.file, + line: m.line, + fix: "Remove the child_process import; use the sanctioned Bun.spawn helpers", + }); + } + }, + }, + "single-ast-method": { + description: + "RuleContext exposes exactly one ast(path, language) method — no per-language variants like pythonAst()/rubyAst()", + severity: "error", + async check(ctx) { + const surfaces = ["src/formats/rules.ts", "src/helpers/rules-shim.ts"]; + const checks = surfaces.map(async (file) => { + const content = await ctx.readFile(file); + const variantMatch = content.match( + /\b(?:python|ruby|typescript|javascript|ts|js|py|rb)Ast\s*\(/iu + ); + if (variantMatch) { + ctx.report.violation({ + message: `Per-language AST method "${variantMatch[0].trim()}" found in ${file} — ARCH-022 mandates a single ast(path, language) method`, + file, + fix: "Fold the per-language variant into the single ast(path, language) dispatch", + }); + } + const astSignatures = content.match( + /^\s*ast\(path: string, language: AstLanguage\): Promise;/gmu + ); + if (!astSignatures || astSignatures.length !== 1) { + ctx.report.violation({ + message: `${file} must declare exactly one \`ast(path: string, language: AstLanguage): Promise\` signature on RuleContext (found ${astSignatures?.length ?? 0})`, + file, + fix: "Declare the single ast() signature on RuleContext", + }); + } + }); + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; From 9b65e6b74210e358916da54c0a7db49464975a2b Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 4 Jul 2026 23:06:35 -0300 Subject: [PATCH 03/16] test(engine): cover ctx.ast support modules and runner integration Unit coverage for js-parser and ast-support (probe, subprocess runner, serializer programs gated on interpreter availability) plus runChecks-level integration tests for the four guardrails, failure semantics, and real Python/Ruby structural rules. Resolves the ARCH-005 gap from the core commit. Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- tests/engine/ast-support.test.ts | 243 ++++++++++++++++++++++++ tests/engine/js-parser.test.ts | 53 ++++++ tests/engine/runner-ast.test.ts | 308 +++++++++++++++++++++++++++++++ 3 files changed, 604 insertions(+) create mode 100644 tests/engine/ast-support.test.ts create mode 100644 tests/engine/js-parser.test.ts create mode 100644 tests/engine/runner-ast.test.ts diff --git a/tests/engine/ast-support.test.ts b/tests/engine/ast-support.test.ts new file mode 100644 index 00000000..7b017a1a --- /dev/null +++ b/tests/engine/ast-support.test.ts @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + PYTHON_AST_PROGRAM, + RUBY_AST_PROGRAM, + interpreterCandidates, + parseAstJson, + parseErrorMessage, + probeInterpreter, + runAstSubprocess, +} from "../../src/engine/ast-support"; +import { isWindows } from "../../src/helpers/platform"; + +// Probe once at load time so interpreter-dependent tests can skipIf cleanly. +const pythonInterpreter = await probeInterpreter( + interpreterCandidates("python") +); +const rubyInterpreter = await probeInterpreter(interpreterCandidates("ruby")); + +describe("interpreterCandidates", () => { + test("ruby has a single candidate", () => { + expect(interpreterCandidates("ruby")).toEqual(["ruby"]); + }); + + test("python candidate order matches the platform", () => { + const expected = isWindows() + ? ["python", "python3"] + : ["python3", "python"]; + expect(interpreterCandidates("python")).toEqual(expected); + }); +}); + +describe("probeInterpreter", () => { + test("returns null when no candidate exists", async () => { + const result = await probeInterpreter([ + "definitely-not-a-real-binary-abc123", + ]); + expect(result).toBeNull(); + }); + + test("returns null for an empty candidate list", async () => { + expect(await probeInterpreter([])).toBeNull(); + }); + + test("skips missing candidates and returns the first working one", async () => { + // process.execPath is the running bun binary — always present and + // `bun --version` exits 0 on every supported platform. + const result = await probeInterpreter([ + "definitely-not-a-real-binary-abc123", + process.execPath, + ]); + expect(result).toBe(process.execPath); + }); +}); + +describe("runAstSubprocess", () => { + test("captures stdout on exit code 0", async () => { + const { exitCode, stdout, stderr } = await runAstSubprocess([ + process.execPath, + "-e", + 'console.log("hello-stdout")', + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("hello-stdout"); + expect(stderr).toBe(""); + }); + + test("captures stderr and the non-zero exit code", async () => { + const { exitCode, stdout, stderr } = await runAstSubprocess([ + process.execPath, + "-e", + 'console.error("boom-stderr"); process.exit(3)', + ]); + expect(exitCode).toBe(3); + expect(stderr).toContain("boom-stderr"); + expect(stdout).toBe(""); + }); +}); + +describe("parseAstJson", () => { + test("returns parsed objects and arrays", () => { + expect( + parseAstJson('{"_type":"Module","body":[]}', "a.py", "python") + ).toEqual({ _type: "Module", body: [] }); + expect(parseAstJson('["program",[]]', "a.rb", "ruby")).toEqual([ + "program", + [], + ]); + }); + + test("throws with an 'invalid JSON output' message on garbage", () => { + expect(() => parseAstJson("not json at all", "a.py", "python")).toThrow( + /invalid JSON output/u + ); + expect(() => parseAstJson("", "b.rb", "ruby")).toThrow( + 'Failed to parse "b.rb" as ruby: interpreter produced invalid JSON output' + ); + }); +}); + +describe("parseErrorMessage", () => { + test("extracts the first item from an AggregateError", () => { + const err = new AggregateError( + [new SyntaxError("unexpected token"), new Error("second")], + "aggregate wrapper" + ); + expect(parseErrorMessage(err)).toContain("unexpected token"); + }); + + test("falls back to the message for an empty AggregateError", () => { + const err = new AggregateError([], "aggregate wrapper"); + expect(parseErrorMessage(err)).toBe("aggregate wrapper"); + }); + + test("returns the message of a plain Error", () => { + expect(parseErrorMessage(new Error("plain message"))).toBe("plain message"); + }); + + test("stringifies non-Error values", () => { + expect(parseErrorMessage("string failure")).toBe("string failure"); + expect(parseErrorMessage(42)).toBe("42"); + }); +}); + +describe("PYTHON_AST_PROGRAM end-to-end", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-ast-py-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test.skipIf(!pythonInterpreter)( + "serializes a valid module to JSON with _type Module", + async () => { + const interpreter = pythonInterpreter ?? "python"; + const file = join(tempDir, "valid.py"); + writeFileSync( + file, + ["def greet(name):", ' return f"hi {name}"', ""].join("\n") + ); + + const { exitCode, stdout } = await runAstSubprocess([ + interpreter, + "-c", + PYTHON_AST_PROGRAM, + file, + ]); + expect(exitCode).toBe(0); + + const tree = JSON.parse(stdout) as { + _type: string; + body: Array<{ _type: string; name?: string; lineno?: number }>; + }; + expect(tree._type).toBe("Module"); + expect(tree.body).toHaveLength(1); + expect(tree.body[0]._type).toBe("FunctionDef"); + expect(tree.body[0].name).toBe("greet"); + expect(tree.body[0].lineno).toBe(1); + } + ); + + test.skipIf(!pythonInterpreter)( + "exits 1 with a syntax message for invalid source", + async () => { + const interpreter = pythonInterpreter ?? "python"; + const file = join(tempDir, "invalid.py"); + writeFileSync(file, "def broken(:\n"); + + const { exitCode, stderr } = await runAstSubprocess([ + interpreter, + "-c", + PYTHON_AST_PROGRAM, + file, + ]); + expect(exitCode).toBe(1); + expect(stderr.toLowerCase()).toContain("syntax"); + } + ); +}); + +describe("RUBY_AST_PROGRAM end-to-end", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-ast-rb-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test.skipIf(!rubyInterpreter)( + "serializes a valid file to a JSON array starting with program", + async () => { + const interpreter = rubyInterpreter ?? "ruby"; + const file = join(tempDir, "valid.rb"); + writeFileSync(file, ["def hello", ' puts "hi"', "end", ""].join("\n")); + + const { exitCode, stdout } = await runAstSubprocess([ + interpreter, + "-rripper", + "-rjson", + "-e", + RUBY_AST_PROGRAM, + file, + ]); + expect(exitCode).toBe(0); + + const sexp = JSON.parse(stdout) as unknown[]; + expect(Array.isArray(sexp)).toBe(true); + expect(sexp[0]).toBe("program"); + } + ); + + test.skipIf(!rubyInterpreter)( + "exits 1 with 'Ruby syntax error' for invalid source", + async () => { + const interpreter = rubyInterpreter ?? "ruby"; + const file = join(tempDir, "invalid.rb"); + writeFileSync(file, "def broken(\n"); + + const { exitCode, stderr } = await runAstSubprocess([ + interpreter, + "-rripper", + "-rjson", + "-e", + RUBY_AST_PROGRAM, + file, + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("Ruby syntax error"); + } + ); +}); diff --git a/tests/engine/js-parser.test.ts b/tests/engine/js-parser.test.ts new file mode 100644 index 00000000..14769b06 --- /dev/null +++ b/tests/engine/js-parser.test.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test } from "bun:test"; + +import { parseJsModule } from "../../src/engine/js-parser"; + +describe("parseJsModule", () => { + test("parses a valid module into an ESTree Program with ordered body", () => { + const program = parseJsModule( + [ + 'import { a } from "./a";', + "const x = a + 1;", + "export function run() {", + " return x;", + "}", + "", + ].join("\n") + ); + + expect(program.type).toBe("Program"); + expect(program.sourceType).toBe("module"); + expect(program.body.map((node) => node.type)).toEqual([ + "ImportDeclaration", + "VariableDeclaration", + "ExportNamedDeclaration", + ]); + }); + + test("throws on syntax errors", () => { + expect(() => parseJsModule("const = ;")).toThrow(); + expect(() => parseJsModule("function ( {")).toThrow(); + }); + + test("rejects JSX by default and parses it with the jsx option", () => { + const source = "export const el =
hi
;"; + + expect(() => parseJsModule(source)).toThrow(); + + const program = parseJsModule(source, { jsx: true }); + expect(program.type).toBe("Program"); + expect(program.body).toHaveLength(1); + expect(program.body[0].type).toBe("ExportNamedDeclaration"); + }); + + test("includes loc information on nodes", () => { + const program = parseJsModule("const x = 1;\nconst y = 2;\n"); + + expect(program.loc).toBeDefined(); + expect(program.body[0].loc?.start.line).toBe(1); + expect(program.body[1].loc?.start.line).toBe(2); + expect(program.body[1].loc?.start.column).toBe(0); + }); +}); diff --git a/tests/engine/runner-ast.test.ts b/tests/engine/runner-ast.test.ts new file mode 100644 index 00000000..ba074b57 --- /dev/null +++ b/tests/engine/runner-ast.test.ts @@ -0,0 +1,308 @@ +// 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 { getExitCode } from "../../src/engine/reporter"; +import { runChecks } from "../../src/engine/runner"; +import type { AdrDocument } from "../../src/formats/adr"; +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") +); +const rubyInterpreter = await probeInterpreter(interpreterCandidates("ruby")); + +/** Recursively collect Python AST nodes matching a predicate. */ +function collectPyNodes( + node: unknown, + predicate: (record: Record) => boolean, + hits: Array> +): void { + if (Array.isArray(node)) { + for (const item of node) collectPyNodes(item, predicate, hits); + return; + } + if (node && typeof node === "object") { + const record = node as Record; + if (predicate(record)) hits.push(record); + for (const value of Object.values(record)) { + collectPyNodes(value, predicate, hits); + } + } +} + +/** Recursively search a Ripper sexp for an @ident token with a given name. */ +function sexpHasIdent(node: unknown, name: string): boolean { + if (!Array.isArray(node)) return false; + if (node[0] === "@ident" && node[1] === name) return true; + return node.some((item) => sexpHasIdent(item, name)); +} + +describe("runChecks ctx.ast()", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-runner-ast-")); + mkdirSync(join(tempDir, "src"), { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + const EMPTY_RULE_SET: RuleSet = { rules: {} }; + + function makeLoadedAdr( + overrides: Partial = {}, + ruleSet: RuleSet = EMPTY_RULE_SET + ): LoadResult { + return { + type: "loaded", + value: { + adr: { + frontmatter: { + id: "AST-001", + title: "AST Test", + domain: "general", + rules: true, + ...overrides, + }, + body: "", + filePath: "/test.md", + }, + ruleSet, + }, + }; + } + + test("typescript: rule walks an ESTree Program and reports from AST evidence", async () => { + writeFileSync( + join(tempDir, "src", "app.ts"), + [ + "interface Config {", + " retries: number;", + "}", + "", + "function hello(config: Config): void {", + " console.log(config.retries);", + "}", + "", + "hello({ retries: 1 });", + "", + ].join("\n") + ); + + let bodyTypes: string[] = []; + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "no-hello-fn": { + description: "Detect a function named hello via the AST", + async check(ctx) { + const program = (await ctx.ast("src/app.ts", "typescript")) as { + type: string; + body: Array<{ + type: string; + id?: { name?: string }; + loc?: { start: { line: number } }; + }>; + }; + bodyTypes = program.body.map((node) => node.type); + for (const node of program.body) { + if ( + node.type === "FunctionDeclaration" && + node.id?.name === "hello" + ) { + ctx.report.violation({ + message: `Function "${node.id.name}" is banned`, + file: "src/app.ts", + }); + } + } + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toBeUndefined(); + expect(result.results[0].violations).toHaveLength(1); + expect(result.results[0].violations[0].message).toContain("hello"); + // Type-only syntax (the interface) is erased before parsing — only the + // runtime statements survive in the Program body. + expect(bodyTypes).toEqual(["FunctionDeclaration", "ExpressionStatement"]); + }); + + test("plausibility guardrail: wrong extension for the language is refused", async () => { + writeFileSync(join(tempDir, "data.json"), "{}"); + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "json-as-python": { + description: "Attempt to parse JSON as Python", + async check(ctx) { + await ctx.ast("data.json", "python"); + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toContain("does not look like python"); + }); + + test("sandbox guardrail: paths escaping the project root are refused", async () => { + const loaded = makeLoadedAdr( + {}, + { + rules: { + "ast-traversal": { + description: "Attempt AST parse outside the project", + async check(ctx) { + await ctx.ast("../outside.py", "python"); + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toContain("escapes project root"); + }); + + test("parse failure surfaces as RuleResult.error without breaking the run", async () => { + writeFileSync(join(tempDir, "src", "broken.ts"), "const = {\n"); + writeFileSync(join(tempDir, "src", "fine.ts"), "export const ok = 1;\n"); + + const loaded = makeLoadedAdr( + { files: ["src/**/*.ts"] }, + { + rules: { + "ast-broken": { + description: "Parse a syntactically broken file", + async check(ctx) { + await ctx.ast("src/broken.ts", "typescript"); + }, + }, + "still-runs": { + description: "Unaffected rule in the same ADR", + async check(ctx) { + const matches = await ctx.grep("src/fine.ts", /ok/u); + if (matches.length === 0) { + ctx.report.violation({ message: "expected content missing" }); + } + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results).toHaveLength(2); + + const broken = result.results.find((r) => r.ruleId === "ast-broken"); + const healthy = result.results.find((r) => r.ruleId === "still-runs"); + expect(broken?.error).toContain("Failed to parse"); + expect(broken?.error).toContain("src/broken.ts"); + expect(healthy?.error).toBeUndefined(); + expect(healthy?.violations).toHaveLength(0); + + // ARCH-022 failure-visibility contract: a rule error is the exit-code-2 + // category, never a silent pass. + expect(getExitCode(result)).toBe(2); + }); + + test.skipIf(!pythonInterpreter)( + "python: rule detects a bare except clause through runChecks", + async () => { + writeFileSync( + join(tempDir, "src", "handler.py"), + ["try:", " risky()", "except:", " pass", ""].join("\n") + ); + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "no-bare-except": { + description: "Disallow bare except: clauses", + async check(ctx) { + const tree = await ctx.ast("src/handler.py", "python"); + const hits: Array> = []; + collectPyNodes( + tree, + (record) => + record._type === "ExceptHandler" && record.type === null, + hits + ); + for (const hit of hits) { + ctx.report.violation({ + message: "Bare except: clause", + file: "src/handler.py", + line: typeof hit.lineno === "number" ? hit.lineno : 0, + }); + } + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toBeUndefined(); + expect(result.results[0].violations).toHaveLength(1); + expect(result.results[0].violations[0].line).toBe(3); + } + ); + + test.skipIf(!rubyInterpreter)( + "ruby: rule detects a method named hello in the sexp through runChecks", + async () => { + writeFileSync( + join(tempDir, "src", "greeter.rb"), + ["def hello", ' puts "hi"', "end", ""].join("\n") + ); + + const loaded = makeLoadedAdr( + {}, + { + rules: { + "no-hello-method": { + description: "Detect a method named hello via Ripper sexp", + async check(ctx) { + const sexp = await ctx.ast("src/greeter.rb", "ruby"); + expect(Array.isArray(sexp)).toBe(true); + expect((sexp as unknown[])[0]).toBe("program"); + if (sexpHasIdent(sexp, "hello")) { + ctx.report.violation({ + message: 'Method "hello" is banned', + file: "src/greeter.rb", + }); + } + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toBeUndefined(); + expect(result.results[0].violations).toHaveLength(1); + } + ); +}); From 2c8a7d5371a38d3701444397ef217ebe6dafdbb4 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 4 Jul 2026 23:06:35 -0300 Subject: [PATCH 04/16] refactor(adr): rewrite ARCH-004/ARCH-008 rules with ctx.ast() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the line-stripping barrel heuristic and the single-line .option() regexes with ESTree structural checks — the two fragile rules ARCH-022 names as motivating consumers. The ARCH-008 rules now catch multi-line .option() calls the old per-line regex missed. Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- .../adrs/ARCH-004-no-barrel-files.rules.ts | 99 ++++++--- .../ARCH-008-typed-command-options.rules.ts | 205 ++++++++++++++---- 2 files changed, 232 insertions(+), 72 deletions(-) diff --git a/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts b/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts index a5bfe85b..b51b7deb 100644 --- a/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts +++ b/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts @@ -1,40 +1,61 @@ /// /** - * Determines whether a file is a barrel (re-export-only index.ts). + * ESTree statement shape (the subset this rule inspects). ctx.ast() returns + * an untyped AstNode, so top-level statements are narrowed through this. + */ +interface EstreeStatement { + type?: unknown; + declaration?: unknown; +} + +/** + * A Program body is barrel-shaped when every top-level statement is purely + * import/re-export plumbing: + * - ImportDeclaration — `import { x } from "./y"` / `import "./y"` + * - ExportAllDeclaration — `export * from "./y"` + * - ExportNamedDeclaration with — `export { x } from "./y"` / `export { x }` + * declaration === null * - * A barrel file contains only export/import statements and no executable - * logic — no function definitions, class definitions, variable declarations, - * or statements beyond re-exports. + * Anything else — `export const x = ...`, `export default ...`, function or + * class declarations, expression statements — is executable logic, so the + * file is not a barrel. */ -function isBarrelFile(content: string): boolean { - const lines = content - .split("\n") - .map((l) => l.trim()) - .filter( - (l) => - l !== "" && - !l.startsWith("//") && - !l.startsWith("/*") && - !l.startsWith("*") +function isReExportOnlyBody(body: unknown[]): boolean { + return body.every((node) => { + const stmt = node as EstreeStatement; + if (stmt.type === "ImportDeclaration") return true; + if (stmt.type === "ExportAllDeclaration") return true; + return ( + stmt.type === "ExportNamedDeclaration" && + (stmt.declaration === null || stmt.declaration === undefined) ); + }); +} - if (lines.length === 0) return false; +/** + * Fallback for files whose transpiled Program body is EMPTY: ctx.ast() + * transpiles TypeScript before parsing, which erases type-only syntax + * (`export type { X } from "./y"`, `import type ...`), so a pure + * type-re-export barrel parses to an empty Program. Conservatively inspect + * the source: strip comments and blank space, then require every remaining + * statement to start with `import` or `export`. A comment-only/empty file + * is not a barrel. + */ +function isTypeOnlyBarrel(source: string): boolean { + const stripped = source + .replaceAll(/\/\*[\s\S]*?\*\//gu, "") + .replaceAll(/\/\/[^\n]*/gu, "") + .trim(); - return lines.every( - (line) => - // export { Foo } from "./bar" / export type { Foo } from "./bar" - line.startsWith("export ") || - line.startsWith("export{") || - // import type { Foo } from "./bar" - line.startsWith("import ") || - // Continuation of multi-line export blocks - line.startsWith("} from") || - line.startsWith("type ") || - /^[A-Za-z_$,\s]+$/u.test(line) || - line === "}" || - line === "};" - ); + if (stripped === "") return false; + + const statements = stripped + .split(";") + .map((s) => s.trim()) + .filter((s) => s !== ""); + + return statements.every((s) => /^(?:import|export)\b/u.test(s)); } export default { @@ -48,8 +69,24 @@ export default { ); const checks = indexFiles.map(async (file) => { - const content = await ctx.readFile(file); - if (isBarrelFile(content)) { + let program: AstNode; + try { + program = await ctx.ast(file, "typescript"); + } catch { + // A syntactically broken index.ts must not kill this rule for + // every other file — typecheck/lint report real syntax errors. + return; + } + + const body = (program as { body?: unknown[] }).body; + if (!Array.isArray(body)) return; + + const barrel = + body.length > 0 + ? isReExportOnlyBody(body) + : isTypeOnlyBarrel(await ctx.readFile(file)); + + if (barrel) { ctx.report.violation({ message: `Barrel file detected: ${file} contains only re-exports and no logic. Import directly from source modules instead.`, file, diff --git a/.archgate/adrs/ARCH-008-typed-command-options.rules.ts b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts index 62aa6f2d..44505795 100644 --- a/.archgate/adrs/ARCH-008-typed-command-options.rules.ts +++ b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts @@ -1,5 +1,100 @@ /// +/** + * ARCH-008 enforcement, rewritten on top of ctx.ast() (ARCH-022). + * + * The previous implementation grepped single lines for `.option(...)` and + * missed any call formatted across multiple lines — exactly the fragility + * ARCH-022 was introduced to fix. These rules now walk the ESTree produced + * by ctx.ast(file, "typescript") and inspect real CallExpression arguments, + * so formatting, whitespace, and string escaping no longer matter. + */ + +interface EsNode { + type?: string; + [key: string]: unknown; +} + +/** + * Description strings that enumerate a fixed set of values, e.g. + * "editor integration to configure (claude, cursor, vscode, copilot)" or + * "ADR domain: backend, frontend, data, architecture, general". + * Same heuristic as the previous regex rule, but applied to the parsed + * Literal VALUE rather than raw source text. + */ +const CHOICE_ENUMERATION = /(?:claude.*cursor|backend.*frontend)/u; + +/** A flag literal like "--editor " — a value-taking long option. */ +const VALUE_TAKING_FLAG = /^--[\w-]+\s+<\w+>/u; + +/** Bare global parsers that must not be passed as .option()'s third arg. */ +const PARSER_IDENTIFIERS = new Set(["parseInt", "parseFloat", "Number"]); + +/** Depth-first walk over an ESTree-shaped tree. */ +function walk(node: unknown, visit: (n: EsNode) => void): void { + if (Array.isArray(node)) { + for (const item of node) walk(item, visit); + return; + } + if (!node || typeof node !== "object") return; + const n = node as EsNode; + if (typeof n.type === "string") visit(n); + for (const value of Object.values(n)) { + if (value && typeof value === "object") walk(value, visit); + } +} + +/** Collect the argument lists of every `.option(...)` call in a tree. */ +function findOptionCallArgs(tree: AstNode): EsNode[][] { + const calls: EsNode[][] = []; + walk(tree, (n) => { + if (n.type !== "CallExpression") return; + const callee = n.callee as EsNode | undefined; + if (callee?.type !== "MemberExpression" || callee.computed === true) return; + const property = callee.property as + | (EsNode & { name?: string }) + | undefined; + if (property?.type !== "Identifier" || property.name !== "option") return; + calls.push((n.arguments as EsNode[] | undefined) ?? []); + }); + return calls; +} + +function isStringLiteral( + node: EsNode | undefined +): node is EsNode & { value: string } { + return node?.type === "Literal" && typeof node.value === "string"; +} + +/** + * Locate the 1-based line of an option's flag string in the ORIGINAL source. + * + * ctx.ast(file, "typescript") parses Bun-transpiled output, which reprints + * the module and collapses multi-line calls onto single lines — node.loc + * therefore refers to transpiled lines and is unusable for reporting. + * Searching the untranspiled source for the quoted flag literal gives an + * exact line instead; when the flag can't be found (e.g. built dynamically), + * the violation is reported file-only rather than with a wrong line. + */ +function findFlagLine(source: string, flag: string): number | undefined { + const needles = [`"${flag}"`, `'${flag}'`, `\`${flag}\``]; + const lines = source.split("\n"); + for (const [index, lineText] of lines.entries()) { + if (needles.some((needle) => lineText.includes(needle))) return index + 1; + } + return undefined; +} + +function reportWithLine( + ctx: RuleContext, + detail: { message: string; file: string; fix: string }, + source: string, + flag: string | undefined +): void { + const line = flag === undefined ? undefined : findFlagLine(source, flag); + ctx.report.violation({ ...detail, ...(line === undefined ? {} : { line }) }); +} + export default { rules: { "use-add-option-for-choices": { @@ -8,28 +103,39 @@ export default { severity: "error", async check(ctx) { const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts")); - // Detect .option() calls whose description enumerates known choice values - // e.g. "editor integration to configure (claude, cursor, vscode, copilot)" - // or "ADR domain: backend, frontend, data, architecture, general" - const matches = await Promise.all( - files.map((file) => - ctx.grep( - file, - /\.option\(\s*["']--\w+\s+<\w+>["'],\s*["'][^"']*(?:claude.*cursor|backend.*frontend)[^"']*["']/u - ) - ) + await Promise.all( + files.map(async (file) => { + const tree = await ctx.ast(file, "typescript"); + // Flag .option(flag, description) calls — exactly two string + // literal args — whose description enumerates fixed choices. + const flagged: string[] = []; + for (const args of findOptionCallArgs(tree)) { + if (args.length !== 2) continue; + const [flag, description] = args; + if (!isStringLiteral(flag) || !isStringLiteral(description)) { + continue; + } + if (!VALUE_TAKING_FLAG.test(flag.value)) continue; + if (!CHOICE_ENUMERATION.test(description.value)) continue; + flagged.push(flag.value); + } + if (flagged.length === 0) return; + const source = await ctx.readFile(file); + for (const flag of flagged) { + reportWithLine( + ctx, + { + message: + "Use new Option().choices() with .addOption() instead of .option() for fixed-choice options", + file, + fix: "Replace .option() with new Option(...).choices([...] as const) and register via .addOption()", + }, + source, + flag + ); + } + }) ); - for (const fileMatches of matches) { - for (const m of fileMatches) { - ctx.report.violation({ - message: - "Use new Option().choices() with .addOption() instead of .option() for fixed-choice options", - file: m.file, - line: m.line, - fix: "Replace .option() with new Option(...).choices([...] as const) and register via .addOption()", - }); - } - } }, }, "use-add-option-for-arg-parser": { @@ -38,27 +144,44 @@ export default { severity: "error", async check(ctx) { const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts")); - // Detect .option() calls that pass a function as the third argument - // e.g. .option("--max-entries ", "...", parseInt) - const matches = await Promise.all( - files.map((file) => - ctx.grep( - file, - /\.option\(\s*["']--\w+\s+<\w+>["'],\s*["'][^"']*["'],\s*(?:parseInt|parseFloat|\()/u - ) - ) + await Promise.all( + files.map(async (file) => { + const tree = await ctx.ast(file, "typescript"); + // Flag .option(flag, description, parser) calls whose third + // argument is a function expression or a bare global parser + // identifier (parseInt/parseFloat/Number). Literal defaults + // (strings, numbers, booleans) remain allowed. + const flagged: (string | undefined)[] = []; + for (const args of findOptionCallArgs(tree)) { + if (args.length < 3) continue; + const third = args[2]; + const isFunctionArg = + third?.type === "ArrowFunctionExpression" || + third?.type === "FunctionExpression"; + const isParserIdentifier = + third?.type === "Identifier" && + PARSER_IDENTIFIERS.has((third as { name?: string }).name ?? ""); + if (!isFunctionArg && !isParserIdentifier) continue; + const flag = args[0]; + flagged.push(isStringLiteral(flag) ? flag.value : undefined); + } + if (flagged.length === 0) return; + const source = await ctx.readFile(file); + for (const flag of flagged) { + reportWithLine( + ctx, + { + message: + "Use new Option().argParser() with .addOption() instead of passing a parser to .option()", + file, + fix: "Replace .option() with new Option(...).argParser((val) => ...) and register via .addOption()", + }, + source, + flag + ); + } + }) ); - for (const fileMatches of matches) { - for (const m of fileMatches) { - ctx.report.violation({ - message: - "Use new Option().argParser() with .addOption() instead of passing a parser to .option()", - file: m.file, - line: m.line, - fix: "Replace .option() with new Option(...).argParser((val) => ...) and register via .addOption()", - }); - } - } }, }, }, From f1e210e7788588c6fdec16f760e175490ca036bc Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 4 Jul 2026 23:06:36 -0300 Subject: [PATCH 05/16] docs: document ctx.ast() with per-language examples (en, nb, pt-br) Adds the ast() method reference, AstNode shapes table, interpreter PATH requirement callouts, and a structural-checks guide section with one example rule per supported language, translated into both locales per GEN-002. Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 278 ++++++++++++++++++ .../src/content/docs/guides/writing-rules.mdx | 230 +++++++++++++++ .../content/docs/nb/guides/writing-rules.mdx | 230 +++++++++++++++ .../content/docs/nb/reference/rule-api.mdx | 50 ++++ .../docs/pt-br/guides/writing-rules.mdx | 230 +++++++++++++++ .../content/docs/pt-br/reference/rule-api.mdx | 50 ++++ docs/src/content/docs/reference/rule-api.mdx | 50 ++++ 7 files changed, 1118 insertions(+) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index e6705596..31c5faee 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -2870,6 +2870,18 @@ Find files by glob pattern. Returns an array of file paths relative to the proje const testFiles = await ctx.glob("tests/**/*.test.ts"); ``` +### ctx.ast(path, language) + +Parse a source file into its language-native AST. Supported languages: `"typescript"`, `"javascript"`, `"python"`, and `"ruby"`. TypeScript and JavaScript parse in-process into an ESTree `Program`; Python and Ruby shell out to the system interpreter's standard-library parser. Throws on parse failure or missing interpreter -- it never returns `null`. + +```typescript +const program = (await ctx.ast("src/cli.ts", "typescript")) as { + body: { type: string }[]; +}; +``` + +See [Structural checks with ctx.ast()](#structural-checks-with-ctxast) below for complete examples, and the [Rule API Reference](/reference/rule-api/#ast) for the returned shape per language. + ### ctx.report The reporting interface with three severity methods: @@ -2900,6 +2912,223 @@ ctx.report.violation({ The absolute path to the project root directory. Useful when you need to construct absolute paths. +## Structural checks with ctx.ast() + +Regex works for surface patterns, but breaks down for structural questions like "does this file contain only re-exports?" or "is this a bare `except:` clause?" -- multi-line statements, comments, and string contents all defeat line-based matching. `ctx.ast()` parses a file into a real syntax tree so your rule can check structure directly. + +The returned tree is language-native, not unified across languages: + +- **TypeScript / JavaScript** -- an [ESTree](https://github.com/estree/estree) `Program` parsed in-process by [meriyah](https://github.com/meriyah/meriyah). TypeScript is transpiled first, so type-only syntax (`interface`, type aliases, `export type { ... } from`) is erased from the tree; a file containing only type-level statements parses to an empty `Program` body. +- **Python** -- the standard-library [`ast` module](https://docs.python.org/3/library/ast.html)'s tree serialized to JSON. Each node is an object with a `_type` field plus the node's own fields and `lineno` / `col_offset` positions. +- **Ruby** -- the standard-library [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html)'s `Ripper.sexp` output: nested arrays like `["program", [["command", ...]]]` with `[line, column]` position pairs. + +`ctx.ast()` throws on parse failure or a missing interpreter -- it never returns `null`. The throw is isolated to the failing rule and surfaces as a rule execution error (exit code 2), so a broken environment shows up as a visible failure rather than a false pass. + +:::caution +Python and Ruby parsing invokes the system interpreter. The corresponding interpreter (`python3`/`python`, `ruby`) must be 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. + +### TypeScript: no barrel files + +A barrel file is an `index.ts` whose every top-level statement is a re-export. With the ESTree `Program`, that question becomes a direct check on statement types instead of a line-matching heuristic: + +```typescript +/// + +export default { + rules: { + "no-barrel-files": { + description: "index.ts files must not be pure re-export barrels", + async check(ctx) { + const indexFiles = ctx.scopedFiles.filter((f) => + f.endsWith("/index.ts") + ); + + const checks = indexFiles.map(async (file) => { + const program = (await ctx.ast(file, "typescript")) as { + body: { type: string; source: { value: string } | null }[]; + }; + + const isBarrel = + program.body.length > 0 && + program.body.every( + (node) => + node.type === "ExportAllDeclaration" || + (node.type === "ExportNamedDeclaration" && node.source !== null) + ); + + if (isBarrel) { + ctx.report.violation({ + message: `Barrel file detected: ${file} contains only re-exports`, + file, + fix: "Delete the barrel and import directly from the source modules", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +Because TypeScript is transpiled before parsing, `export type { ... } from` re-exports are erased -- a barrel containing only type re-exports parses to an empty `body`, which the `body.length > 0` guard skips. If you also need to flag type-only barrels, combine the AST check with a text check. + +### JavaScript: no require() in ES modules + +Walk the ESTree tree for `CallExpression` nodes whose callee is the identifier `require`. A recursive walk over object values and arrays covers every node type without enumerating them: + +```typescript +/// + +function findRequireCalls(node: unknown, lines: number[]): void { + if (Array.isArray(node)) { + for (const item of node) findRequireCalls(item, lines); + return; + } + if (node === null || typeof node !== "object") return; + const record = node as Record; + const callee = record.callee as Record | undefined; + if ( + record.type === "CallExpression" && + callee?.type === "Identifier" && + callee.name === "require" + ) { + const loc = record.loc as { start: { line: number } }; + lines.push(loc.start.line); + } + for (const value of Object.values(record)) findRequireCalls(value, lines); +} + +export default { + rules: { + "no-require-in-esm": { + description: ".mjs files must not call CommonJS require()", + async check(ctx) { + const files = await ctx.glob("src/**/*.mjs"); + + const checks = files.map(async (file) => { + const program = await ctx.ast(file, "javascript"); + const lines: number[] = []; + findRequireCalls(program, lines); + for (const line of lines) { + ctx.report.violation({ + message: "CommonJS require() call in an ES module", + file, + line, + fix: "Use a static import or await import() instead", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +### Python: no bare except clauses + +In the Python `ast` module, an `except:` clause is an `ExceptHandler` node whose `type` field holds the caught exception expression -- a bare `except:` has `"type": null`. Walk the JSON tree for that shape: + +```typescript +/// + +function findBareExcepts(node: unknown, lines: number[]): void { + if (Array.isArray(node)) { + for (const item of node) findBareExcepts(item, lines); + return; + } + if (node === null || typeof node !== "object") return; + const record = node as Record; + if (record._type === "ExceptHandler" && record.type === null) { + lines.push(record.lineno as number); + } + for (const value of Object.values(record)) findBareExcepts(value, lines); +} + +export default { + rules: { + "no-bare-except": { + description: "Python code must not use bare except: clauses", + async check(ctx) { + const files = await ctx.glob("**/*.py"); + + const checks = files.map(async (file) => { + const tree = await ctx.ast(file, "python"); + const lines: number[] = []; + findBareExcepts(tree, lines); + for (const line of lines) { + ctx.report.violation({ + message: + "Bare except: catches every exception, including SystemExit", + file, + line, + fix: "Catch a specific exception class, e.g. except ValueError:", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +### Ruby: no puts calls + +`Ripper.sexp` represents `puts "hello"` as `["command", ["@ident", "puts", [1, 0]], [...args]]` -- the `[line, column]` pair sits inside the `@ident` token. Walk the nested arrays for that shape: + +```typescript +/// + +function findPutsCalls(node: unknown, lines: number[]): void { + if (!Array.isArray(node)) return; + const [kind, first] = node; + if ( + kind === "command" && + Array.isArray(first) && + first[0] === "@ident" && + first[1] === "puts" + ) { + const [line] = first[2] as [number, number]; + lines.push(line); + } + for (const child of node) findPutsCalls(child, lines); +} + +export default { + rules: { + "no-puts": { + description: "Ruby code must use the application logger, not puts", + async check(ctx) { + const files = await ctx.glob("app/**/*.rb"); + + const checks = files.map(async (file) => { + const sexp = await ctx.ast(file, "ruby"); + const lines: number[] = []; + findPutsCalls(sexp, lines); + for (const line of lines) { + ctx.report.violation({ + message: "puts writes to stdout directly", + file, + line, + fix: "Replace puts with logger.info", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +Ripper uses a different node shape for the parenthesized form -- `puts("hello")` appears under a `method_add_arg` / `fcall` pair rather than `command` -- so a production rule would match the `fcall` token the same way. + ## Severity levels Each rule can set a default severity in its configuration. The severity determines how violations are treated: @@ -4887,6 +5116,7 @@ interface RuleContext { grepFiles(pattern: RegExp, fileGlob: string): Promise; readFile(path: string): Promise; readJSON(path: string): Promise; + ast(path: string, language: AstLanguage): Promise; report: RuleReport; } ``` @@ -4989,6 +5219,34 @@ const pkg = (await ctx.readJSON("package.json")) as { }; ``` +#### ast + +```typescript +ast( + path: string, + language: "typescript" | "javascript" | "python" | "ruby" +): Promise; +``` + +Parse a source file into its language-native AST. The path is relative to the project root and passes through the same sandbox as `readFile`. TypeScript and JavaScript are parsed in-process; Python and Ruby are parsed by invoking the system interpreter's own standard-library AST facility as a subprocess. The returned tree shape differs per language -- see [AstNode](#astnode). + +```typescript +const program = (await ctx.ast("src/cli.ts", "typescript")) as { + body: { type: string }[]; +}; +``` + +`ast()` **throws** on failure -- it never returns `null`: + +- **Parse failure**: the file does not parse as the requested language. The error message includes the parser's diagnostic. +- **Missing interpreter** (`python`/`ruby` only): no suitable interpreter was found on `PATH`. +- **Implausible input**: the file's extension does not match the requested language (e.g. `ctx.ast("config.json", "python")` throws before any interpreter is invoked). + +A thrown error is isolated to the failing rule: other rules and ADRs in the same check run continue normally, and the failure surfaces as a rule execution error with exit code 2 (distinct from exit code 1 for violations). The two throw cases are distinguishable by message text, so check output tells "this environment cannot run this rule" apart from "this file has a syntax error". + +:::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. + --- ## RuleReport @@ -5077,6 +5335,26 @@ interface GrepMatch { --- +## AstNode + +Returned by `ctx.ast()`. The shape is language-native and deliberately **not** unified across languages -- each language returns its own standard AST vocabulary, so a rule inspecting Python code works against a different grammar than one inspecting TypeScript. + +```typescript +type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; +type AstNode = Record | unknown[]; +``` + +| Language | Backing parser | Returned shape | +| ------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `typescript` | [meriyah](https://github.com/meriyah/meriyah), in-process, after transpiling TypeScript away with `Bun.Transpiler` | [ESTree](https://github.com/estree/estree) `Program` with `loc` position info. Type-only syntax (`interface`, type aliases, `export type { ... } from`) is erased before parsing -- a file containing only type-level statements parses to an empty `Program` body | +| `javascript` | [meriyah](https://github.com/meriyah/meriyah), in-process | [ESTree](https://github.com/estree/estree) `Program` with `loc` position info | +| `python` | Python's standard-library [`ast` module](https://docs.python.org/3/library/ast.html), via the system interpreter | JSON-serialized `ast` nodes: `{ "_type": "Module", "body": [...] }` -- each node carries `_type`, the node's own fields, and `lineno` / `col_offset` positions | +| `ruby` | Ruby's standard-library [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html), via the system interpreter | `Ripper.sexp` nested arrays: `["program", [["command", ...]]]` with `[line, column]` position pairs embedded in token entries | + +See [Structural checks with ctx.ast()](/guides/writing-rules/#structural-checks-with-ctxast) for a complete example rule per language. + +--- + ## Severity ```typescript diff --git a/docs/src/content/docs/guides/writing-rules.mdx b/docs/src/content/docs/guides/writing-rules.mdx index 3ce4696b..f1c3047e 100644 --- a/docs/src/content/docs/guides/writing-rules.mdx +++ b/docs/src/content/docs/guides/writing-rules.mdx @@ -174,6 +174,18 @@ Find files by glob pattern. Returns an array of file paths relative to the proje const testFiles = await ctx.glob("tests/**/*.test.ts"); ``` +### ctx.ast(path, language) + +Parse a source file into its language-native AST. Supported languages: `"typescript"`, `"javascript"`, `"python"`, and `"ruby"`. TypeScript and JavaScript parse in-process into an ESTree `Program`; Python and Ruby shell out to the system interpreter's standard-library parser. Throws on parse failure or missing interpreter -- it never returns `null`. + +```typescript +const program = (await ctx.ast("src/cli.ts", "typescript")) as { + body: { type: string }[]; +}; +``` + +See [Structural checks with ctx.ast()](#structural-checks-with-ctxast) below for complete examples, and the [Rule API Reference](/reference/rule-api/#ast) for the returned shape per language. + ### ctx.report The reporting interface with three severity methods: @@ -204,6 +216,224 @@ ctx.report.violation({ The absolute path to the project root directory. Useful when you need to construct absolute paths. +## Structural checks with ctx.ast() + +Regex works for surface patterns, but breaks down for structural questions like "does this file contain only re-exports?" or "is this a bare `except:` clause?" -- multi-line statements, comments, and string contents all defeat line-based matching. `ctx.ast()` parses a file into a real syntax tree so your rule can check structure directly. + +The returned tree is language-native, not unified across languages: + +- **TypeScript / JavaScript** -- an [ESTree](https://github.com/estree/estree) `Program` parsed in-process by [meriyah](https://github.com/meriyah/meriyah). TypeScript is transpiled first, so type-only syntax (`interface`, type aliases, `export type { ... } from`) is erased from the tree; a file containing only type-level statements parses to an empty `Program` body. +- **Python** -- the standard-library [`ast` module](https://docs.python.org/3/library/ast.html)'s tree serialized to JSON. Each node is an object with a `_type` field plus the node's own fields and `lineno` / `col_offset` positions. +- **Ruby** -- the standard-library [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html)'s `Ripper.sexp` output: nested arrays like `["program", [["command", ...]]]` with `[line, column]` position pairs. + +`ctx.ast()` throws on parse failure or a missing interpreter -- it never returns `null`. The throw is isolated to the failing rule and surfaces as a rule execution error (exit code 2), so a broken environment shows up as a visible failure rather than a false pass. + +:::caution +Python and Ruby parsing invokes the system interpreter. The corresponding interpreter (`python3`/`python`, `ruby`) must be 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. +::: + +### TypeScript: no barrel files + +A barrel file is an `index.ts` whose every top-level statement is a re-export. With the ESTree `Program`, that question becomes a direct check on statement types instead of a line-matching heuristic: + +```typescript +/// + +export default { + rules: { + "no-barrel-files": { + description: "index.ts files must not be pure re-export barrels", + async check(ctx) { + const indexFiles = ctx.scopedFiles.filter((f) => + f.endsWith("/index.ts") + ); + + const checks = indexFiles.map(async (file) => { + const program = (await ctx.ast(file, "typescript")) as { + body: { type: string; source: { value: string } | null }[]; + }; + + const isBarrel = + program.body.length > 0 && + program.body.every( + (node) => + node.type === "ExportAllDeclaration" || + (node.type === "ExportNamedDeclaration" && node.source !== null) + ); + + if (isBarrel) { + ctx.report.violation({ + message: `Barrel file detected: ${file} contains only re-exports`, + file, + fix: "Delete the barrel and import directly from the source modules", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +Because TypeScript is transpiled before parsing, `export type { ... } from` re-exports are erased -- a barrel containing only type re-exports parses to an empty `body`, which the `body.length > 0` guard skips. If you also need to flag type-only barrels, combine the AST check with a text check. + +### JavaScript: no require() in ES modules + +Walk the ESTree tree for `CallExpression` nodes whose callee is the identifier `require`. A recursive walk over object values and arrays covers every node type without enumerating them: + +```typescript +/// + +function findRequireCalls(node: unknown, lines: number[]): void { + if (Array.isArray(node)) { + for (const item of node) findRequireCalls(item, lines); + return; + } + if (node === null || typeof node !== "object") return; + const record = node as Record; + const callee = record.callee as Record | undefined; + if ( + record.type === "CallExpression" && + callee?.type === "Identifier" && + callee.name === "require" + ) { + const loc = record.loc as { start: { line: number } }; + lines.push(loc.start.line); + } + for (const value of Object.values(record)) findRequireCalls(value, lines); +} + +export default { + rules: { + "no-require-in-esm": { + description: ".mjs files must not call CommonJS require()", + async check(ctx) { + const files = await ctx.glob("src/**/*.mjs"); + + const checks = files.map(async (file) => { + const program = await ctx.ast(file, "javascript"); + const lines: number[] = []; + findRequireCalls(program, lines); + for (const line of lines) { + ctx.report.violation({ + message: "CommonJS require() call in an ES module", + file, + line, + fix: "Use a static import or await import() instead", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +### Python: no bare except clauses + +In the Python `ast` module, an `except:` clause is an `ExceptHandler` node whose `type` field holds the caught exception expression -- a bare `except:` has `"type": null`. Walk the JSON tree for that shape: + +```typescript +/// + +function findBareExcepts(node: unknown, lines: number[]): void { + if (Array.isArray(node)) { + for (const item of node) findBareExcepts(item, lines); + return; + } + if (node === null || typeof node !== "object") return; + const record = node as Record; + if (record._type === "ExceptHandler" && record.type === null) { + lines.push(record.lineno as number); + } + for (const value of Object.values(record)) findBareExcepts(value, lines); +} + +export default { + rules: { + "no-bare-except": { + description: "Python code must not use bare except: clauses", + async check(ctx) { + const files = await ctx.glob("**/*.py"); + + const checks = files.map(async (file) => { + const tree = await ctx.ast(file, "python"); + const lines: number[] = []; + findBareExcepts(tree, lines); + for (const line of lines) { + ctx.report.violation({ + message: + "Bare except: catches every exception, including SystemExit", + file, + line, + fix: "Catch a specific exception class, e.g. except ValueError:", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +### Ruby: no puts calls + +`Ripper.sexp` represents `puts "hello"` as `["command", ["@ident", "puts", [1, 0]], [...args]]` -- the `[line, column]` pair sits inside the `@ident` token. Walk the nested arrays for that shape: + +```typescript +/// + +function findPutsCalls(node: unknown, lines: number[]): void { + if (!Array.isArray(node)) return; + const [kind, first] = node; + if ( + kind === "command" && + Array.isArray(first) && + first[0] === "@ident" && + first[1] === "puts" + ) { + const [line] = first[2] as [number, number]; + lines.push(line); + } + for (const child of node) findPutsCalls(child, lines); +} + +export default { + rules: { + "no-puts": { + description: "Ruby code must use the application logger, not puts", + async check(ctx) { + const files = await ctx.glob("app/**/*.rb"); + + const checks = files.map(async (file) => { + const sexp = await ctx.ast(file, "ruby"); + const lines: number[] = []; + findPutsCalls(sexp, lines); + for (const line of lines) { + ctx.report.violation({ + message: "puts writes to stdout directly", + file, + line, + fix: "Replace puts with logger.info", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +Ripper uses a different node shape for the parenthesized form -- `puts("hello")` appears under a `method_add_arg` / `fcall` pair rather than `command` -- so a production rule would match the `fcall` token the same way. + ## Severity levels Each rule can set a default severity in its configuration. The severity determines how violations are treated: diff --git a/docs/src/content/docs/nb/guides/writing-rules.mdx b/docs/src/content/docs/nb/guides/writing-rules.mdx index 2eaa9a4c..54058f58 100644 --- a/docs/src/content/docs/nb/guides/writing-rules.mdx +++ b/docs/src/content/docs/nb/guides/writing-rules.mdx @@ -174,6 +174,18 @@ Finn filer etter glob-mønster. Returnerer en matrise med filstier relative til const testFiles = await ctx.glob("tests/**/*.test.ts"); ``` +### ctx.ast(path, language) + +Parse en kildefil til dens språknative AST. Støttede språk: `"typescript"`, `"javascript"`, `"python"` og `"ruby"`. TypeScript og JavaScript parses i prosessen til en ESTree `Program`; Python og Ruby starter systemtolkens parser fra standardbiblioteket som en underprosess. Kaster ved parsefeil eller manglende tolk -- den returnerer aldri `null`. + +```typescript +const program = (await ctx.ast("src/cli.ts", "typescript")) as { + body: { type: string }[]; +}; +``` + +Se [Strukturelle sjekker med ctx.ast()](#strukturelle-sjekker-med-ctxast) nedenfor for komplette eksempler, og [Regel-API-referansen](/reference/rule-api/#ast) for den returnerte formen per språk. + ### ctx.report Rapporteringsgrensesnittet med tre alvorlighetsgradsmetoder: @@ -204,6 +216,224 @@ ctx.report.violation({ Den absolutte stien til prosjektets rotkatalog. Nyttig når du trenger å konstruere absolutte stier. +## Strukturelle sjekker med ctx.ast() + +Regulære uttrykk fungerer for overflatemønstre, men kommer til kort for strukturelle spørsmål som "inneholder denne filen bare re-eksporter?" eller "er dette en naken `except:`-klausul?" -- flerlinjesetninger, kommentarer og strenginnhold slår alle beina under linjebasert matching. `ctx.ast()` parser en fil til et ekte syntakstre slik at regelen din kan sjekke strukturen direkte. + +Det returnerte treet er språknativt, ikke enhetlig på tvers av språk: + +- **TypeScript / JavaScript** -- en [ESTree](https://github.com/estree/estree) `Program` parset i prosessen av [meriyah](https://github.com/meriyah/meriyah). TypeScript transpileres først, så typenivå-syntaks (`interface`, typealiaser, `export type { ... } from`) fjernes fra treet; en fil som bare inneholder setninger på typenivå parses til en tom `Program`-body. +- **Python** -- treet fra standardbibliotekets [`ast`-modul](https://docs.python.org/3/library/ast.html) serialisert til JSON. Hver node er et objekt med et `_type`-felt pluss nodens egne felt og posisjonene `lineno` / `col_offset`. +- **Ruby** -- `Ripper.sexp`-utdata fra standardbibliotekets [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html): nestede matriser som `["program", [["command", ...]]]` med `[line, column]`-posisjonspar. + +`ctx.ast()` kaster ved parsefeil eller manglende tolk -- den returnerer aldri `null`. Kastet er isolert til regelen som feiler og vises som en regelkjøringsfeil (exit-kode 2), slik at et ødelagt miljø dukker opp som en synlig feil i stedet for en falsk godkjenning. + +:::caution +Python- og Ruby-parsing starter systemtolken. Den tilhørende tolken (`python3`/`python`, `ruby`) må 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. +::: + +### TypeScript: ingen barrel-filer + +En barrel-fil er en `index.ts` der hver setning på toppnivå er en re-eksport. Med ESTree-`Program`-et blir det spørsmålet en direkte sjekk av setningstyper i stedet for en linjematchende heuristikk: + +```typescript +/// + +export default { + rules: { + "no-barrel-files": { + description: "index.ts files must not be pure re-export barrels", + async check(ctx) { + const indexFiles = ctx.scopedFiles.filter((f) => + f.endsWith("/index.ts") + ); + + const checks = indexFiles.map(async (file) => { + const program = (await ctx.ast(file, "typescript")) as { + body: { type: string; source: { value: string } | null }[]; + }; + + const isBarrel = + program.body.length > 0 && + program.body.every( + (node) => + node.type === "ExportAllDeclaration" || + (node.type === "ExportNamedDeclaration" && node.source !== null) + ); + + if (isBarrel) { + ctx.report.violation({ + message: `Barrel file detected: ${file} contains only re-exports`, + file, + fix: "Delete the barrel and import directly from the source modules", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +Fordi TypeScript transpileres før parsing, fjernes `export type { ... } from`-re-eksporter -- en barrel som bare inneholder type-re-eksporter parses til en tom `body`, som `body.length > 0`-vakten hopper over. Hvis du også trenger å flagge rene type-barrels, kombiner AST-sjekken med en tekstsjekk. + +### JavaScript: ingen require() i ES-moduler + +Gå gjennom ESTree-treet etter `CallExpression`-noder der callee er identifikatoren `require`. En rekursiv gjennomgang av objektverdier og matriser dekker alle nodetyper uten å måtte liste dem opp: + +```typescript +/// + +function findRequireCalls(node: unknown, lines: number[]): void { + if (Array.isArray(node)) { + for (const item of node) findRequireCalls(item, lines); + return; + } + if (node === null || typeof node !== "object") return; + const record = node as Record; + const callee = record.callee as Record | undefined; + if ( + record.type === "CallExpression" && + callee?.type === "Identifier" && + callee.name === "require" + ) { + const loc = record.loc as { start: { line: number } }; + lines.push(loc.start.line); + } + for (const value of Object.values(record)) findRequireCalls(value, lines); +} + +export default { + rules: { + "no-require-in-esm": { + description: ".mjs files must not call CommonJS require()", + async check(ctx) { + const files = await ctx.glob("src/**/*.mjs"); + + const checks = files.map(async (file) => { + const program = await ctx.ast(file, "javascript"); + const lines: number[] = []; + findRequireCalls(program, lines); + for (const line of lines) { + ctx.report.violation({ + message: "CommonJS require() call in an ES module", + file, + line, + fix: "Use a static import or await import() instead", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +### Python: ingen nakne except-klausuler + +I Pythons `ast`-modul er en `except:`-klausul en `ExceptHandler`-node der `type`-feltet holder uttrykket for unntaket som fanges -- en naken `except:` har `"type": null`. Gå gjennom JSON-treet etter den formen: + +```typescript +/// + +function findBareExcepts(node: unknown, lines: number[]): void { + if (Array.isArray(node)) { + for (const item of node) findBareExcepts(item, lines); + return; + } + if (node === null || typeof node !== "object") return; + const record = node as Record; + if (record._type === "ExceptHandler" && record.type === null) { + lines.push(record.lineno as number); + } + for (const value of Object.values(record)) findBareExcepts(value, lines); +} + +export default { + rules: { + "no-bare-except": { + description: "Python code must not use bare except: clauses", + async check(ctx) { + const files = await ctx.glob("**/*.py"); + + const checks = files.map(async (file) => { + const tree = await ctx.ast(file, "python"); + const lines: number[] = []; + findBareExcepts(tree, lines); + for (const line of lines) { + ctx.report.violation({ + message: + "Bare except: catches every exception, including SystemExit", + file, + line, + fix: "Catch a specific exception class, e.g. except ValueError:", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +### Ruby: ingen puts-kall + +`Ripper.sexp` representerer `puts "hello"` som `["command", ["@ident", "puts", [1, 0]], [...args]]` -- `[line, column]`-paret ligger inne i `@ident`-tokenet. Gå gjennom de nestede matrisene etter den formen: + +```typescript +/// + +function findPutsCalls(node: unknown, lines: number[]): void { + if (!Array.isArray(node)) return; + const [kind, first] = node; + if ( + kind === "command" && + Array.isArray(first) && + first[0] === "@ident" && + first[1] === "puts" + ) { + const [line] = first[2] as [number, number]; + lines.push(line); + } + for (const child of node) findPutsCalls(child, lines); +} + +export default { + rules: { + "no-puts": { + description: "Ruby code must use the application logger, not puts", + async check(ctx) { + const files = await ctx.glob("app/**/*.rb"); + + const checks = files.map(async (file) => { + const sexp = await ctx.ast(file, "ruby"); + const lines: number[] = []; + findPutsCalls(sexp, lines); + for (const line of lines) { + ctx.report.violation({ + message: "puts writes to stdout directly", + file, + line, + fix: "Replace puts with logger.info", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +Ripper bruker en annen nodeform for varianten med parenteser -- `puts("hello")` vises under et `method_add_arg` / `fcall`-par i stedet for `command` -- så en produksjonsregel ville matchet `fcall`-tokenet på samme måte. + ## Alvorlighetsgrader Hver regel kan sette en standard alvorlighetsgrad i konfigurasjonen sin. Alvorlighetsgraden bestemmer hvordan brudd behandles: diff --git a/docs/src/content/docs/nb/reference/rule-api.mdx b/docs/src/content/docs/nb/reference/rule-api.mdx index 31d682a0..2f679b6b 100644 --- a/docs/src/content/docs/nb/reference/rule-api.mdx +++ b/docs/src/content/docs/nb/reference/rule-api.mdx @@ -65,6 +65,7 @@ interface RuleContext { grepFiles(pattern: RegExp, fileGlob: string): Promise; readFile(path: string): Promise; readJSON(path: string): Promise; + ast(path: string, language: AstLanguage): Promise; report: RuleReport; } ``` @@ -167,6 +168,35 @@ const pkg = (await ctx.readJSON("package.json")) as { }; ``` +#### ast + +```typescript +ast( + path: string, + language: "typescript" | "javascript" | "python" | "ruby" +): Promise; +``` + +Parse en kildefil til dens språknative AST. Stien er relativ til prosjektroten og går gjennom samme sandkasse som `readFile`. TypeScript og JavaScript parses i prosessen; Python og Ruby parses ved å starte systemtolkens egen AST-fasilitet fra standardbiblioteket som en underprosess. Formen på det returnerte treet varierer per språk -- se [AstNode](#astnode). + +```typescript +const program = (await ctx.ast("src/cli.ts", "typescript")) as { + body: { type: string }[]; +}; +``` + +`ast()` **kaster** ved feil -- den returnerer aldri `null`: + +- **Parsefeil**: filen kan ikke parses som det forespurte språket. Feilmeldingen inkluderer parserens diagnostikk. +- **Manglende tolk** (kun `python`/`ruby`): ingen egnet tolk ble funnet på `PATH`. +- **Usannsynlig input**: filens filendelse samsvarer ikke med det forespurte språket (f.eks. kaster `ctx.ast("config.json", "python")` før noen tolk startes). + +En kastet feil er isolert til regelen som feiler: andre regler og ADR-er i samme sjekk-kjøring fortsetter som normalt, og feilen vises som en regelkjøringsfeil med avslutningskode 2 (til forskjell fra avslutningskode 1 for brudd). De to kastetilfellene kan skilles fra hverandre på meldingsteksten, slik at sjekkutdataene skiller "dette miljøet kan ikke kjøre denne regelen" fra "denne filen har en syntaksfeil". + +:::caution +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. +::: + --- ## RuleReport @@ -255,6 +285,26 @@ interface GrepMatch { --- +## AstNode + +Returnert av `ctx.ast()`. Formen er språknativ og bevisst **ikke** enhetlig på tvers av språk -- hvert språk returnerer sitt eget standard AST-vokabular, så en regel som inspiserer Python-kode jobber mot en annen grammatikk enn en som inspiserer TypeScript. + +```typescript +type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; +type AstNode = Record | unknown[]; +``` + +| Språk | Underliggende parser | Returnert form | +| ------------ | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `typescript` | [meriyah](https://github.com/meriyah/meriyah), i prosessen, etter at TypeScript er transpilert bort med `Bun.Transpiler` | [ESTree](https://github.com/estree/estree) `Program` med `loc`-posisjonsinfo. Typenivå-syntaks (`interface`, typealiaser, `export type { ... } from`) fjernes før parsing -- en fil som bare inneholder setninger på typenivå parses til en tom `Program`-body | +| `javascript` | [meriyah](https://github.com/meriyah/meriyah), i prosessen | [ESTree](https://github.com/estree/estree) `Program` med `loc`-posisjonsinfo | +| `python` | Pythons [`ast`-modul](https://docs.python.org/3/library/ast.html) fra standardbiblioteket, via systemtolken | JSON-serialiserte `ast`-noder: `{ "_type": "Module", "body": [...] }` -- hver node har `_type`, nodens egne felt og posisjonene `lineno` / `col_offset` | +| `ruby` | Rubys [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html) fra standardbiblioteket, via systemtolken | Nestede `Ripper.sexp`-matriser: `["program", [["command", ...]]]` med `[line, column]`-posisjonspar innebygd i token-oppføringene | + +Se [Strukturelle sjekker med ctx.ast()](/guides/writing-rules/#strukturelle-sjekker-med-ctxast) for et komplett eksempel på en regel per språk. + +--- + ## Severity ```typescript diff --git a/docs/src/content/docs/pt-br/guides/writing-rules.mdx b/docs/src/content/docs/pt-br/guides/writing-rules.mdx index 6a8e035d..8f90dd03 100644 --- a/docs/src/content/docs/pt-br/guides/writing-rules.mdx +++ b/docs/src/content/docs/pt-br/guides/writing-rules.mdx @@ -174,6 +174,18 @@ Encontra arquivos por padrão glob. Retorna um array de caminhos de arquivo rela const testFiles = await ctx.glob("tests/**/*.test.ts"); ``` +### ctx.ast(path, language) + +Faz o parse de um arquivo-fonte para sua AST nativa da linguagem. Linguagens suportadas: `"typescript"`, `"javascript"`, `"python"` e `"ruby"`. TypeScript e JavaScript são parseados in-process para um `Program` ESTree; Python e Ruby invocam como subprocesso o parser da biblioteca padrão do interpretador do sistema. Lança uma exceção em caso de falha de parse ou interpretador ausente -- nunca retorna `null`. + +```typescript +const program = (await ctx.ast("src/cli.ts", "typescript")) as { + body: { type: string }[]; +}; +``` + +Veja [Verificações estruturais com ctx.ast()](#verificações-estruturais-com-ctxast) abaixo para exemplos completos, e a [Referência da API de Regras](/reference/rule-api/#ast) para o formato retornado por linguagem. + ### ctx.report A interface de relatório com três métodos de severidade: @@ -204,6 +216,224 @@ ctx.report.violation({ O caminho absoluto para o diretório raiz do projeto. Útil quando você precisa construir caminhos absolutos. +## Verificações estruturais com ctx.ast() + +Regex funciona para padrões de superfície, mas falha em perguntas estruturais como "este arquivo contém apenas re-exports?" ou "isto é uma cláusula `except:` vazia?" -- declarações multilinha, comentários e conteúdo de strings derrotam a correspondência baseada em linhas. `ctx.ast()` faz o parse de um arquivo para uma árvore de sintaxe real, permitindo que sua regra verifique a estrutura diretamente. + +A árvore retornada é nativa da linguagem, não unificada entre linguagens: + +- **TypeScript / JavaScript** -- um `Program` [ESTree](https://github.com/estree/estree) parseado in-process pelo [meriyah](https://github.com/meriyah/meriyah). O TypeScript é transpilado primeiro, então sintaxe exclusivamente de tipos (`interface`, type aliases, `export type { ... } from`) é apagada da árvore; um arquivo contendo apenas declarações de nível de tipo parseia para um `Program` com `body` vazio. +- **Python** -- a árvore do [módulo `ast`](https://docs.python.org/3/library/ast.html) da biblioteca padrão serializada em JSON. Cada nó é um objeto com um campo `_type` mais os campos do próprio nó e as posições `lineno` / `col_offset`. +- **Ruby** -- a saída de `Ripper.sexp` do [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html) da biblioteca padrão: arrays aninhados como `["program", [["command", ...]]]` com pares de posição `[linha, coluna]`. + +`ctx.ast()` lança uma exceção em caso de falha de parse ou de interpretador ausente -- nunca retorna `null`. A exceção fica isolada à regra que falhou e aparece como um erro de execução de regra (código de saída 2), então um ambiente quebrado se manifesta como uma falha visível em vez de um falso sucesso. + +:::caution +O parsing de Python e Ruby invoca o interpretador do sistema. O interpretador correspondente (`python3`/`python`, `ruby`) deve estar 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. +::: + +### TypeScript: sem barrel files + +Um barrel file é um `index.ts` cujas declarações de nível superior são todas re-exports. Com o `Program` ESTree, essa pergunta se torna uma verificação direta sobre os tipos das declarações, em vez de uma heurística de correspondência de linhas: + +```typescript +/// + +export default { + rules: { + "no-barrel-files": { + description: "index.ts files must not be pure re-export barrels", + async check(ctx) { + const indexFiles = ctx.scopedFiles.filter((f) => + f.endsWith("/index.ts") + ); + + const checks = indexFiles.map(async (file) => { + const program = (await ctx.ast(file, "typescript")) as { + body: { type: string; source: { value: string } | null }[]; + }; + + const isBarrel = + program.body.length > 0 && + program.body.every( + (node) => + node.type === "ExportAllDeclaration" || + (node.type === "ExportNamedDeclaration" && node.source !== null) + ); + + if (isBarrel) { + ctx.report.violation({ + message: `Barrel file detected: ${file} contains only re-exports`, + file, + fix: "Delete the barrel and import directly from the source modules", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +Como o TypeScript é transpilado antes do parse, re-exports `export type { ... } from` são apagados -- um barrel contendo apenas re-exports de tipos parseia para um `body` vazio, que a guarda `body.length > 0` ignora. Se você também precisa sinalizar barrels exclusivamente de tipos, combine a verificação de AST com uma verificação textual. + +### JavaScript: sem require() em módulos ES + +Percorra a árvore ESTree em busca de nós `CallExpression` cujo callee é o identificador `require`. Um percurso recursivo sobre valores de objetos e arrays cobre todos os tipos de nó sem enumerá-los: + +```typescript +/// + +function findRequireCalls(node: unknown, lines: number[]): void { + if (Array.isArray(node)) { + for (const item of node) findRequireCalls(item, lines); + return; + } + if (node === null || typeof node !== "object") return; + const record = node as Record; + const callee = record.callee as Record | undefined; + if ( + record.type === "CallExpression" && + callee?.type === "Identifier" && + callee.name === "require" + ) { + const loc = record.loc as { start: { line: number } }; + lines.push(loc.start.line); + } + for (const value of Object.values(record)) findRequireCalls(value, lines); +} + +export default { + rules: { + "no-require-in-esm": { + description: ".mjs files must not call CommonJS require()", + async check(ctx) { + const files = await ctx.glob("src/**/*.mjs"); + + const checks = files.map(async (file) => { + const program = await ctx.ast(file, "javascript"); + const lines: number[] = []; + findRequireCalls(program, lines); + for (const line of lines) { + ctx.report.violation({ + message: "CommonJS require() call in an ES module", + file, + line, + fix: "Use a static import or await import() instead", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +### Python: sem cláusulas except vazias + +No módulo `ast` do Python, uma cláusula `except:` é um nó `ExceptHandler` cujo campo `type` guarda a expressão da exceção capturada -- um `except:` vazio tem `"type": null`. Percorra a árvore JSON em busca desse formato: + +```typescript +/// + +function findBareExcepts(node: unknown, lines: number[]): void { + if (Array.isArray(node)) { + for (const item of node) findBareExcepts(item, lines); + return; + } + if (node === null || typeof node !== "object") return; + const record = node as Record; + if (record._type === "ExceptHandler" && record.type === null) { + lines.push(record.lineno as number); + } + for (const value of Object.values(record)) findBareExcepts(value, lines); +} + +export default { + rules: { + "no-bare-except": { + description: "Python code must not use bare except: clauses", + async check(ctx) { + const files = await ctx.glob("**/*.py"); + + const checks = files.map(async (file) => { + const tree = await ctx.ast(file, "python"); + const lines: number[] = []; + findBareExcepts(tree, lines); + for (const line of lines) { + ctx.report.violation({ + message: + "Bare except: catches every exception, including SystemExit", + file, + line, + fix: "Catch a specific exception class, e.g. except ValueError:", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +### Ruby: sem chamadas puts + +O `Ripper.sexp` representa `puts "hello"` como `["command", ["@ident", "puts", [1, 0]], [...args]]` -- o par `[linha, coluna]` fica dentro do token `@ident`. Percorra os arrays aninhados em busca desse formato: + +```typescript +/// + +function findPutsCalls(node: unknown, lines: number[]): void { + if (!Array.isArray(node)) return; + const [kind, first] = node; + if ( + kind === "command" && + Array.isArray(first) && + first[0] === "@ident" && + first[1] === "puts" + ) { + const [line] = first[2] as [number, number]; + lines.push(line); + } + for (const child of node) findPutsCalls(child, lines); +} + +export default { + rules: { + "no-puts": { + description: "Ruby code must use the application logger, not puts", + async check(ctx) { + const files = await ctx.glob("app/**/*.rb"); + + const checks = files.map(async (file) => { + const sexp = await ctx.ast(file, "ruby"); + const lines: number[] = []; + findPutsCalls(sexp, lines); + for (const line of lines) { + ctx.report.violation({ + message: "puts writes to stdout directly", + file, + line, + fix: "Replace puts with logger.info", + }); + } + }); + + await Promise.all(checks); + }, + }, + }, +} satisfies RuleSet; +``` + +O Ripper usa um formato de nó diferente para a forma com parênteses -- `puts("hello")` aparece sob um par `method_add_arg` / `fcall`, em vez de `command` -- então uma regra de produção casaria o token `fcall` da mesma maneira. + ## Níveis de severidade Cada regra pode definir uma severidade padrão em sua configuração. A severidade determina como as violações são tratadas: 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 214d6850..8331bc72 100644 --- a/docs/src/content/docs/pt-br/reference/rule-api.mdx +++ b/docs/src/content/docs/pt-br/reference/rule-api.mdx @@ -65,6 +65,7 @@ interface RuleContext { grepFiles(pattern: RegExp, fileGlob: string): Promise; readFile(path: string): Promise; readJSON(path: string): Promise; + ast(path: string, language: AstLanguage): Promise; report: RuleReport; } ``` @@ -167,6 +168,35 @@ const pkg = (await ctx.readJSON("package.json")) as { }; ``` +#### ast + +```typescript +ast( + path: string, + language: "typescript" | "javascript" | "python" | "ruby" +): Promise; +``` + +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](#astnode). + +```typescript +const program = (await ctx.ast("src/cli.ts", "typescript")) as { + body: { type: string }[]; +}; +``` + +`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). + +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 dois 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". + +:::caution +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. +::: + --- ## RuleReport @@ -255,6 +285,26 @@ interface GrepMatch { --- +## AstNode + +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. + +```typescript +type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; +type AstNode = Record | unknown[]; +``` + +| Linguagem | Parser utilizado | Formato retornado | +| ------------ | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `typescript` | [meriyah](https://github.com/meriyah/meriyah), in-process, após remover o TypeScript por transpilação com `Bun.Transpiler` | `Program` [ESTree](https://github.com/estree/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 | +| `javascript` | [meriyah](https://github.com/meriyah/meriyah), in-process | `Program` [ESTree](https://github.com/estree/estree) com informações de posição em `loc` | +| `python` | O [módulo `ast`](https://docs.python.org/3/library/ast.html) da biblioteca padrão do Python, via interpretador do sistema | Nó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` | +| `ruby` | O [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html) da biblioteca padrão do Ruby, via interpretador do sistema | Arrays 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()](/guides/writing-rules/#verificações-estruturais-com-ctxast) para um exemplo completo de regra por linguagem. + +--- + ## Severity ```typescript diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx index cff22121..899c318c 100644 --- a/docs/src/content/docs/reference/rule-api.mdx +++ b/docs/src/content/docs/reference/rule-api.mdx @@ -65,6 +65,7 @@ interface RuleContext { grepFiles(pattern: RegExp, fileGlob: string): Promise; readFile(path: string): Promise; readJSON(path: string): Promise; + ast(path: string, language: AstLanguage): Promise; report: RuleReport; } ``` @@ -167,6 +168,35 @@ const pkg = (await ctx.readJSON("package.json")) as { }; ``` +#### ast + +```typescript +ast( + path: string, + language: "typescript" | "javascript" | "python" | "ruby" +): Promise; +``` + +Parse a source file into its language-native AST. The path is relative to the project root and passes through the same sandbox as `readFile`. TypeScript and JavaScript are parsed in-process; Python and Ruby are parsed by invoking the system interpreter's own standard-library AST facility as a subprocess. The returned tree shape differs per language -- see [AstNode](#astnode). + +```typescript +const program = (await ctx.ast("src/cli.ts", "typescript")) as { + body: { type: string }[]; +}; +``` + +`ast()` **throws** on failure -- it never returns `null`: + +- **Parse failure**: the file does not parse as the requested language. The error message includes the parser's diagnostic. +- **Missing interpreter** (`python`/`ruby` only): no suitable interpreter was found on `PATH`. +- **Implausible input**: the file's extension does not match the requested language (e.g. `ctx.ast("config.json", "python")` throws before any interpreter is invoked). + +A thrown error is isolated to the failing rule: other rules and ADRs in the same check run continue normally, and the failure surfaces as a rule execution error with exit code 2 (distinct from exit code 1 for violations). The two throw cases are distinguishable by message text, so check output tells "this environment cannot run this rule" apart from "this file has a syntax error". + +:::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. +::: + --- ## RuleReport @@ -255,6 +285,26 @@ interface GrepMatch { --- +## AstNode + +Returned by `ctx.ast()`. The shape is language-native and deliberately **not** unified across languages -- each language returns its own standard AST vocabulary, so a rule inspecting Python code works against a different grammar than one inspecting TypeScript. + +```typescript +type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; +type AstNode = Record | unknown[]; +``` + +| Language | Backing parser | Returned shape | +| ------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `typescript` | [meriyah](https://github.com/meriyah/meriyah), in-process, after transpiling TypeScript away with `Bun.Transpiler` | [ESTree](https://github.com/estree/estree) `Program` with `loc` position info. Type-only syntax (`interface`, type aliases, `export type { ... } from`) is erased before parsing -- a file containing only type-level statements parses to an empty `Program` body | +| `javascript` | [meriyah](https://github.com/meriyah/meriyah), in-process | [ESTree](https://github.com/estree/estree) `Program` with `loc` position info | +| `python` | Python's standard-library [`ast` module](https://docs.python.org/3/library/ast.html), via the system interpreter | JSON-serialized `ast` nodes: `{ "_type": "Module", "body": [...] }` -- each node carries `_type`, the node's own fields, and `lineno` / `col_offset` positions | +| `ruby` | Ruby's standard-library [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html), via the system interpreter | `Ripper.sexp` nested arrays: `["program", [["command", ...]]]` with `[line, column]` position pairs embedded in token entries | + +See [Structural checks with ctx.ast()](/guides/writing-rules/#structural-checks-with-ctxast) for a complete example rule per language. + +--- + ## Severity ```typescript From a5998a7208889bce80824a555884d84fb5f0df5d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sat, 4 Jul 2026 23:07:16 -0300 Subject: [PATCH 06/16] fix(engine): strip UTF-8 BOM before parsing in Python/Ruby serializers Python's utf-8 codec preserves the BOM as U+FEFF, which ast.parse rejects as a syntax error; utf-8-sig strips it. Ruby gets the equivalent r:bom|utf-8 read mode. Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- src/engine/ast-support.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engine/ast-support.ts b/src/engine/ast-support.ts index a1f466c6..8098f717 100644 --- a/src/engine/ast-support.ts +++ b/src/engine/ast-support.ts @@ -57,7 +57,7 @@ def convert(node): return node return repr(node) -with open(sys.argv[1], encoding="utf-8") as handle: +with open(sys.argv[1], encoding="utf-8-sig") as handle: source = handle.read() try: tree = ast.parse(source, filename=sys.argv[1]) @@ -73,7 +73,7 @@ print(json.dumps(convert(tree))) * `max_nesting: false` because real-world ASTs exceed JSON's default depth. */ export const RUBY_AST_PROGRAM = ` -source = File.read(ARGV[0], encoding: "UTF-8") +source = File.read(ARGV[0], mode: "r:bom|utf-8") sexp = Ripper.sexp(source) if sexp.nil? warn "Ruby syntax error" From f6a95da128c6a2c604d1403091cb7e4c24ce3525 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 5 Jul 2026 08:06:38 -0300 Subject: [PATCH 07/16] fix(engine): harden ctx.ast from adversarial review - Python subprocess runs with -I (isolated mode): without it, `python -c` puts the target project cwd on sys.path, letting a hostile project shadow stdlib modules (ast.py/json.py) and run arbitrary code. Verified end-to-end against a shadow ast.py. - Windows interpreter probe adds the `py` launcher: the python.org installer registers it even when PATH opt-in is unchecked. - .cjs files parse in sloppy script mode (globalReturn) so CommonJS top-level return no longer fails under module grammar. - ARCH-008 choices rule uses args.length < 2, not !== 2, so a fixed-choice .option() with a trailing default value is still flagged (regression vs the old regex). - AstNode JSDoc + ARCH-022 note the transpiled-loc caveat and py. Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- .../ARCH-008-typed-command-options.rules.ts | 7 +++--- .../adrs/ARCH-022-ast-aware-rule-context.md | 4 ++-- src/engine/ast-support.ts | 8 +++++-- src/engine/js-parser.ts | 24 ++++++++++++++----- src/engine/runner.ts | 11 ++++++++- src/formats/rules.ts | 6 ++++- src/helpers/rules-shim.ts | 6 ++++- 7 files changed, 50 insertions(+), 16 deletions(-) diff --git a/.archgate/adrs/ARCH-008-typed-command-options.rules.ts b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts index 44505795..16d96230 100644 --- a/.archgate/adrs/ARCH-008-typed-command-options.rules.ts +++ b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts @@ -106,11 +106,12 @@ export default { await Promise.all( files.map(async (file) => { const tree = await ctx.ast(file, "typescript"); - // Flag .option(flag, description) calls — exactly two string - // literal args — whose description enumerates fixed choices. + // Flag .option(flag, description, ...) calls — at least two + // string literal args (a trailing default value or parser must + // not exempt the call) — whose description enumerates choices. const flagged: string[] = []; for (const args of findOptionCallArgs(tree)) { - if (args.length !== 2) continue; + if (args.length < 2) continue; const [flag, description] = args; if (!isStringLiteral(flag) || !isStringLiteral(description)) { continue; diff --git a/.archgate/adrs/ARCH-022-ast-aware-rule-context.md b/.archgate/adrs/ARCH-022-ast-aware-rule-context.md index f9a98f54..1f9b9cea 100644 --- a/.archgate/adrs/ARCH-022-ast-aware-rule-context.md +++ b/.archgate/adrs/ARCH-022-ast-aware-rule-context.md @@ -37,13 +37,13 @@ ast(path: string, language: "typescript" | "javascript" | "python" | "ruby"): Pr This method dispatches internally based on `language`, and the dispatch mechanism MUST be invisible to rule authors — a `.rules.ts` file calls `ctx.ast(path, language)` and receives a parsed tree or an exception; it never sees which mechanism produced it. - **`"typescript"` / `"javascript"`** MUST reuse the in-process `meriyah` parser already used by `src/engine/rule-scanner.ts`. No subprocess is spawned for this branch. The inline `parseModule()` invocation currently duplicated in `scanRuleSource()` and `scanImportedRuleSource()` MUST be factored into a shared, exported parse helper that both `rule-scanner.ts` and the new `ctx.ast()` implementation call, rather than introducing a third inline copy. -- **`"python"` / `"ruby"`** MUST invoke the language's own standard-library AST facility as a subprocess via `Bun.spawn`, per [ARCH-007](./ARCH-007-cross-platform-subprocess-execution.md): Python's built-in `ast` module (` -c "..."`, serializing the tree to JSON), Ruby's built-in `Ripper` (`ruby -rripper -rjson -e "..."`, serializing its s-expression output to JSON). `` is whichever candidate name (`python3`/`python`) the interpreter availability probe below resolved for this platform — never hardcoded. No third-party parser, native binding, or WASM grammar is introduced for these languages under this decision. +- **`"python"` / `"ruby"`** MUST invoke the language's own standard-library AST facility as a subprocess via `Bun.spawn`, per [ARCH-007](./ARCH-007-cross-platform-subprocess-execution.md): Python's built-in `ast` module (` -c "..."`, serializing the tree to JSON), Ruby's built-in `Ripper` (`ruby -rripper -rjson -e "..."`, serializing its s-expression output to JSON). `` is whichever candidate name (`python3`/`python`, plus the `py` launcher on Windows) the interpreter availability probe below resolved for this platform — never hardcoded. No third-party parser, native binding, or WASM grammar is introduced for these languages under this decision. **Guardrail ordering — this is the core architectural constraint of this ADR.** A rule author MUST NEVER be able to reach `Bun.spawn`, `child_process`, or any other subprocess/filesystem primitive directly; `ctx.ast()` is the only door, exactly as `glob`/`grep`/`readFile` are today, and this is consistent with the sandbox `rule-scanner.ts` already enforces on `.rules.ts` source (which explicitly blocks `Bun.spawn` and `Bun.spawnSync` from rule code). All of the following MUST execute inside `createRuleContext()` in `src/engine/runner.ts`, in this order, before any subprocess is spawned: 1. **Path safety** — the requested `path` MUST pass through the same `safePath()` sandboxing already applied to `readFile`/`glob` (no traversal outside `scopedFiles`, no symlink escapes). 2. **Language plausibility check** — the file's extension and/or leading content MUST be sanity-checked against the requested `language` before any interpreter is invoked on it. A rule calling `ctx.ast("config.json", "python")` MUST fail this check rather than hand arbitrary file content to a Python interpreter. -3. **Interpreter availability probe** — for `"python"`/`"ruby"`, an availability check (e.g. `Bun.spawn([candidate, "--version"])` wrapped in `try/catch`, following the exact pattern `isClaudeCliAvailable()` uses in ARCH-007) MUST run before the real invocation. `python3` is not a universal PATH alias on Windows (the common installer exposes `python`, not `python3`); the probe MUST try platform-appropriate candidate executable names in order (e.g. `python3` then `python` on non-Windows, `python` then `python3` on Windows, using [ARCH-009](./ARCH-009-platform-detection-helper.md)'s `isWindows()`) and use the first one that resolves for both the probe and the real invocation. This probe result MUST be cached once per `check` invocation, not re-run per file. +3. **Interpreter availability probe** — for `"python"`/`"ruby"`, an availability check (e.g. `Bun.spawn([candidate, "--version"])` wrapped in `try/catch`, following the exact pattern `isClaudeCliAvailable()` uses in ARCH-007) MUST run before the real invocation. `python3` is not a universal PATH alias on Windows (the common installer exposes `python`, not `python3`); the probe MUST try platform-appropriate candidate executable names in order (e.g. `python3` then `python` on non-Windows; `python`, then `python3`, then the `py` launcher on Windows — the python.org installer registers `py` even when "Add python.exe to PATH" is unchecked — using [ARCH-009](./ARCH-009-platform-detection-helper.md)'s `isWindows()`) and use the first one that resolves for both the probe and the real invocation. This probe result MUST be cached once per `check` invocation, not re-run per file. 4. **Guarded invocation** — the actual `Bun.spawn` call MUST use array-based arguments only, per ARCH-007, with no shell interpolation of file contents or paths. **Failure semantics.** `ctx.ast()` MUST throw — it MUST NOT return `null` or any other sentinel — both when the required interpreter is unavailable and when the target file fails to parse. This is a deliberate choice, not an oversight: this ADR does not introduce any new error-boundary or exit-code behavior, and none is needed, because `ctx.ast()`'s failure mode composes directly with contracts `src/engine/runner.ts` and `src/engine/reporter.ts` already implement. Every rule's `check(ctx)` call already runs inside a per-rule `try/catch` (`runner.ts`, the loop over `Object.entries(ruleSet.rules)`) that isolates a thrown error to that single rule — other ADRs and rules in the same `check` run continue and report normally. `reporter.ts`'s `getExitCode()` already reserves exit code `2` specifically for rule execution errors, distinct from exit `1` (ADR violations found) and exit `0` (pass). A thrown `ctx.ast()` error therefore surfaces as a visible, correctly-categorized failure through machinery that already exists; a `null` return would instead let a rule silently no-op and report as a false "0 violations," masking a real capability gap as a pass. The exit-code/reporter distinction is coarse by design (exit `2` means "a rule could not complete," full stop) — the two throw cases MUST still be distinguishable from each other in the thrown error's message text (e.g. "Python interpreter not found on PATH" vs. "Failed to parse ``: ``"), since a user reading `check` output needs to tell "this environment can't run this rule" apart from "this specific file has a syntax error" even though both land on the same exit code. diff --git a/src/engine/ast-support.ts b/src/engine/ast-support.ts index 8098f717..e1642686 100644 --- a/src/engine/ast-support.ts +++ b/src/engine/ast-support.ts @@ -85,11 +85,15 @@ puts JSON.generate(sexp, max_nesting: false) /** * Candidate executable names per language, in probe order. `python3` is not * a universal PATH alias on Windows (the common installer exposes `python`), - * so the order flips per platform (ARCH-009's isWindows()). + * so the order flips per platform (ARCH-009's isWindows()). Windows also + * probes the `py` launcher last — the python.org installer registers it + * unconditionally even when "Add python.exe to PATH" is left unchecked, and + * the probe already rejects a stale launcher with no registered CPython + * (`py --version` exits non-zero). */ export function interpreterCandidates(language: "python" | "ruby"): string[] { if (language === "ruby") return ["ruby"]; - return isWindows() ? ["python", "python3"] : ["python3", "python"]; + return isWindows() ? ["python", "python3", "py"] : ["python3", "python"]; } /** diff --git a/src/engine/js-parser.ts b/src/engine/js-parser.ts index b663d053..a7adebef 100644 --- a/src/engine/js-parser.ts +++ b/src/engine/js-parser.ts @@ -1,24 +1,36 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { parseModule } from "meriyah"; +import { parseModule, parseScript } from "meriyah"; -/** ESTree Program produced by meriyah's `parseModule`. */ +/** ESTree Program produced by meriyah's `parseModule`/`parseScript`. */ export type EsTreeProgram = ReturnType; /** - * Parse JavaScript module source into an ESTree AST via meriyah. + * Parse JavaScript source into an ESTree AST via meriyah. * - * This is the single sanctioned `parseModule()` call site, shared by the - * rule-file sandbox scanner (`rule-scanner.ts`) and the `ctx.ast()` + * This is the single sanctioned meriyah call site, shared by the rule-file + * sandbox scanner (`rule-scanner.ts`) and the `ctx.ast()` * TypeScript/JavaScript branch in `runner.ts` — per ARCH-022, the parse * call must not be duplicated inline at each consumer. * + * `sourceType: "script"` parses sloppy-mode CommonJS (used for `.cjs` + * files, which cannot legally contain import/export in Node). It enables + * `globalReturn` because Node allows top-level `return` in CJS modules. + * * Throws on syntax errors; callers decide how to surface them. */ export function parseJsModule( source: string, - options?: { jsx?: boolean } + options?: { jsx?: boolean; sourceType?: "module" | "script" } ): EsTreeProgram { + if (options?.sourceType === "script") { + return parseScript(source, { + next: true, + loc: true, + globalReturn: true, + ...(options?.jsx ? { jsx: true } : {}), + }); + } return parseModule(source, { next: true, loc: true, diff --git a/src/engine/runner.ts b/src/engine/runner.ts index dccc0516..dc755fff 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -316,8 +316,12 @@ function createRuleContext( const js = new Bun.Transpiler({ loader }).transformSync(source); return parseJsModule(js) as unknown as AstNode; } + // .cjs cannot legally contain import/export in Node — parse it as + // a sloppy-mode script so CommonJS-isms (top-level return, `with`) + // do not fail under module/strict grammar. return parseJsModule(source, { jsx: lowerPath.endsWith(".jsx"), + sourceType: lowerPath.endsWith(".cjs") ? "script" : "module", }) as unknown as AstNode; } catch (err) { throw new Error( @@ -343,9 +347,14 @@ function createRuleContext( } // Guardrail 4: guarded invocation — array args only, path via argv. + // Python runs in isolated mode (-I): without it, `python -c` puts the + // cwd (the target project root) on sys.path, so a hostile project + // could shadow stdlib modules (ast.py, json.py) and execute arbitrary + // code when the serializer imports them. Ruby is safe as-is — its + // load path has not included the cwd since 1.9.2. const cmd = language === "python" - ? [interpreter, "-c", PYTHON_AST_PROGRAM, absPath] + ? [interpreter, "-I", "-c", PYTHON_AST_PROGRAM, absPath] : [ interpreter, "-rripper", diff --git a/src/formats/rules.ts b/src/formats/rules.ts index d0fdbfb7..a63af9b9 100644 --- a/src/formats/rules.ts +++ b/src/formats/rules.ts @@ -73,7 +73,11 @@ export type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; * The shape is language-native and deliberately NOT unified across languages * (see ARCH-022): * - `"typescript"` / `"javascript"` — ESTree Program (meriyah). TypeScript is - * transpiled before parsing, so type-only syntax is erased from the tree. + * transpiled before parsing, so type-only syntax is erased from the tree + * AND `loc` positions refer to the transpiled output, not the original + * file — re-locate matches in the original source before reporting line + * numbers. `loc` is source-accurate for `"javascript"`, which parses the + * file directly. * - `"python"` — the standard `ast` module tree serialized to JSON; each node * is `{ _type: "Name", ...fields, lineno, col_offset, ... }`. * - `"ruby"` — `Ripper.sexp` output: nested arrays such as diff --git a/src/helpers/rules-shim.ts b/src/helpers/rules-shim.ts index bd3137fd..76c5cdc7 100644 --- a/src/helpers/rules-shim.ts +++ b/src/helpers/rules-shim.ts @@ -76,7 +76,11 @@ declare type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; * Root node returned by \`RuleContext.ast()\`. The shape is language-native * and deliberately NOT unified across languages: * - "typescript" / "javascript" — ESTree Program (meriyah). TypeScript is - * transpiled before parsing, so type-only syntax is erased from the tree. + * transpiled before parsing, so type-only syntax is erased from the tree + * AND \`loc\` positions refer to the transpiled output, not the original + * file — re-locate matches in the original source before reporting line + * numbers. \`loc\` is source-accurate for "javascript", which parses the + * file directly. * - "python" — the standard \`ast\` module tree serialized to JSON; each node * is \`{ _type: "Name", ...fields, lineno, col_offset, ... }\`. * - "ruby" — \`Ripper.sexp\` output: nested arrays such as From 33a87526de0eccece974ca43f3bbc2fc8bf3169b Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 5 Jul 2026 08:28:23 -0300 Subject: [PATCH 08/16] test(engine): cover ctx.ast gaps found in review Adds coverage for Ruby extensionless basenames (Rakefile/Gemfile), .tsx/.jsx dispatch, .cjs sloppy-script mode vs .mjs rejection, per-language plausibility rejection, and a security regression guard that the Python -I isolation blocks a hostile cwd ast.py shadow. Updates the Windows candidate-order assertion for the py launcher. Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- tests/engine/ast-support.test.ts | 2 +- tests/engine/runner-ast.test.ts | 204 ++++++++++++++++++++++++++++++- 2 files changed, 203 insertions(+), 3 deletions(-) diff --git a/tests/engine/ast-support.test.ts b/tests/engine/ast-support.test.ts index 7b017a1a..470100cd 100644 --- a/tests/engine/ast-support.test.ts +++ b/tests/engine/ast-support.test.ts @@ -29,7 +29,7 @@ describe("interpreterCandidates", () => { test("python candidate order matches the platform", () => { const expected = isWindows() - ? ["python", "python3"] + ? ["python", "python3", "py"] : ["python3", "python"]; expect(interpreterCandidates("python")).toEqual(expected); }); diff --git a/tests/engine/runner-ast.test.ts b/tests/engine/runner-ast.test.ts index ba074b57..ca077763 100644 --- a/tests/engine/runner-ast.test.ts +++ b/tests/engine/runner-ast.test.ts @@ -1,7 +1,13 @@ // 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 { + existsSync, + mkdtempSync, + rmSync, + mkdirSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -13,7 +19,7 @@ import type { LoadResult } from "../../src/engine/loader"; import { getExitCode } from "../../src/engine/reporter"; import { runChecks } from "../../src/engine/runner"; import type { AdrDocument } from "../../src/formats/adr"; -import type { RuleSet } from "../../src/formats/rules"; +import type { RuleContext, RuleSet } from "../../src/formats/rules"; // Probe once at load time so interpreter-dependent tests can skipIf cleanly. const pythonInterpreter = await probeInterpreter( @@ -305,4 +311,198 @@ describe("runChecks ctx.ast()", () => { expect(result.results[0].violations).toHaveLength(1); } ); + + test.skipIf(!rubyInterpreter)( + "ruby: extensionless Rakefile and Gemfile basenames are accepted", + async () => { + // "Rakefile"/"Gemfile" carry no extension — plausibility relies on the + // lowercased-basename membership in RUBY_BASENAMES. + writeFileSync(join(tempDir, "Rakefile"), "task :default do\nend\n"); + writeFileSync(join(tempDir, "Gemfile"), 'gem "rake"\n'); + + const roots: unknown[] = []; + const loaded = makeLoadedAdr( + {}, + { + rules: { + "ruby-basenames": { + description: "Parse extensionless Ruby basenames", + async check(ctx) { + const rakefile = await ctx.ast("Rakefile", "ruby"); + const gemfile = await ctx.ast("Gemfile", "ruby"); + roots.push( + (rakefile as unknown[])[0], + (gemfile as unknown[])[0] + ); + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toBeUndefined(); + expect(roots).toEqual(["program", "program"]); + } + ); + + test("tsx/jsx dispatch: JSX parses under the tsx loader and the jsx branch", async () => { + // The .tsx file mixes type-only syntax with a JSX element — it must go + // through the `loader: "tsx"` Bun.Transpiler branch to survive both. + writeFileSync( + join(tempDir, "src", "App.tsx"), + "type Props = { name: string };\nexport function App(props: Props) {\n return
{props.name}
;\n}\n" + ); + // The .jsx file exercises the meriyah `jsx: true` branch (no transpile). + writeFileSync( + join(tempDir, "src", "widget.jsx"), + "export const el = hi;\n" + ); + + const programTypes: string[] = []; + const loaded = makeLoadedAdr( + {}, + { + rules: { + "jsx-dispatch": { + description: "Parse .tsx as typescript and .jsx as javascript", + async check(ctx) { + const tsx = (await ctx.ast("src/App.tsx", "typescript")) as { + type: string; + }; + const jsx = (await ctx.ast("src/widget.jsx", "javascript")) as { + type: string; + }; + programTypes.push(tsx.type, jsx.type); + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + expect(result.results[0].error).toBeUndefined(); + expect(programTypes).toEqual(["Program", "Program"]); + }); + + test(".cjs parses in sloppy script mode while .mjs rejects top-level return", async () => { + // Node permits a top-level `return` in CommonJS files but never in ESM, + // so the same source must parse as .cjs and throw as .mjs — proving the + // .cjs sourceType special-case is real, not incidental. + const source = + "if (process.env.ARCHGATE_DISABLED) return;\nmodule.exports = { ok: true };\n"; + writeFileSync(join(tempDir, "src", "legacy.cjs"), source); + writeFileSync(join(tempDir, "src", "modern.mjs"), source); + + let cjsSourceType = ""; + const loaded = makeLoadedAdr( + {}, + { + rules: { + "cjs-script-mode": { + description: "Top-level return is legal in .cjs", + async check(ctx) { + const program = (await ctx.ast( + "src/legacy.cjs", + "javascript" + )) as { type: string; sourceType: string }; + cjsSourceType = program.sourceType; + }, + }, + "mjs-module-mode": { + description: "Top-level return is illegal in .mjs", + async check(ctx) { + await ctx.ast("src/modern.mjs", "javascript"); + }, + }, + }, + } + ); + + const result = await runChecks(tempDir, [loaded]); + const cjs = result.results.find((r) => r.ruleId === "cjs-script-mode"); + const mjs = result.results.find((r) => r.ruleId === "mjs-module-mode"); + expect(cjs?.error).toBeUndefined(); + expect(cjsSourceType).toBe("script"); + expect(mjs?.error).toContain("Failed to parse"); + expect(mjs?.error).toContain("src/modern.mjs"); + }); + + test.skipIf(!pythonInterpreter)( + "python: -I isolation prevents a project-local ast.py from shadowing the stdlib", + async () => { + // Security regression guard: without `-I`, `python -c` prepends the + // cwd to sys.path, so a hostile project shipping its own ast.py would + // execute arbitrary code when the serializer does `import ast`. + const sentinel = join(tempDir, "shadow-sentinel.txt"); + writeFileSync( + join(tempDir, "ast.py"), + `open(${JSON.stringify(sentinel.replaceAll("\\", "/"))}, "w").write("x")\nraise SystemExit("SHADOW EXECUTED")\n` + ); + writeFileSync(join(tempDir, "target.py"), "x = 1\n"); + + let treeType = ""; + const loaded = makeLoadedAdr( + {}, + { + rules: { + "shadow-guard": { + description: "Parse target.py despite a malicious ast.py", + async check(ctx) { + const tree = (await ctx.ast("target.py", "python")) as { + _type: string; + }; + treeType = tree._type; + }, + }, + }, + } + ); + + // Run with cwd inside the hostile project — the realistic `archgate + // check` invocation — and always restore it afterwards. + const prevCwd = process.cwd(); + process.chdir(tempDir); + let result; + try { + result = await runChecks(tempDir, [loaded]); + } finally { + process.chdir(prevCwd); + } + + expect(result.results[0].error).toBeUndefined(); + expect(treeType).toBe("Module"); + expect(existsSync(sentinel)).toBe(false); + } + ); + + test("plausibility guardrail applies per language, not only python", async () => { + writeFileSync(join(tempDir, "data.json"), "{}"); + const languages = ["ruby", "typescript", "javascript"] as const; + + const loaded = makeLoadedAdr( + {}, + { + rules: Object.fromEntries( + languages.map((language) => [ + `json-as-${language}`, + { + description: `Attempt to parse JSON as ${language}`, + async check(ctx: RuleContext) { + await ctx.ast("data.json", language); + }, + }, + ]) + ), + } + ); + + const result = await runChecks(tempDir, [loaded]); + const byId = new Map(result.results.map((r) => [r.ruleId, r])); + for (const language of languages) { + expect(byId.get(`json-as-${language}`)?.error).toContain( + `does not look like ${language}` + ); + } + }); }); From d03ca38eec83ed4a1945d0d09c9798e97ee9b13d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 5 Jul 2026 08:28:23 -0300 Subject: [PATCH 09/16] docs: note transpiled-loc caveat for typescript ctx.ast (en, nb, pt-br) For language "typescript", loc positions refer to Bun.Transpiler output, not the original .ts source, so line numbers drift. Documents that rules must re-locate matches in the original source before reporting a line; loc is source-accurate only for "javascript". Regenerates llms-full.txt. Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 16 +++++++++------- docs/src/content/docs/guides/writing-rules.mdx | 4 +++- .../src/content/docs/nb/guides/writing-rules.mdx | 4 +++- docs/src/content/docs/nb/reference/rule-api.mdx | 12 ++++++------ .../content/docs/pt-br/guides/writing-rules.mdx | 4 +++- .../content/docs/pt-br/reference/rule-api.mdx | 12 ++++++------ docs/src/content/docs/reference/rule-api.mdx | 12 ++++++------ 7 files changed, 36 insertions(+), 28 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 31c5faee..75b4ae4b 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -2972,7 +2972,7 @@ export default { } satisfies RuleSet; ``` -Because TypeScript is transpiled before parsing, `export type { ... } from` re-exports are erased -- a barrel containing only type re-exports parses to an empty `body`, which the `body.length > 0` guard skips. If you also need to flag type-only barrels, combine the AST check with a text check. +Because TypeScript is transpiled before parsing, `export type { ... } from` re-exports are erased -- a barrel containing only type re-exports parses to an empty `body`, which the `body.length > 0` guard skips. If you also need to flag type-only barrels, combine the AST check with a text check. The same transpilation also shifts positions: `loc` line numbers refer to the transpiled text, not your original `.ts` file, so a `"typescript"` rule must re-locate the construct in the original source (for example with `ctx.readFile()` and `indexOf`) before reporting a `line` -- or omit `line`. ### JavaScript: no require() in ES modules @@ -3028,6 +3028,8 @@ export default { } satisfies RuleSet; ``` +The `loc.start.line` reporting here is valid only because `"javascript"` parses the original source untranspiled -- a `"typescript"` rule must locate the line in the original source instead, since its `loc` refers to the transpiled output. + ### Python: no bare except clauses In the Python `ast` module, an `except:` clause is an `ExceptHandler` node whose `type` field holds the caught exception expression -- a bare `except:` has `"type": null`. Walk the JSON tree for that shape: @@ -5344,12 +5346,12 @@ type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; type AstNode = Record | unknown[]; ``` -| Language | Backing parser | Returned shape | -| ------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `typescript` | [meriyah](https://github.com/meriyah/meriyah), in-process, after transpiling TypeScript away with `Bun.Transpiler` | [ESTree](https://github.com/estree/estree) `Program` with `loc` position info. Type-only syntax (`interface`, type aliases, `export type { ... } from`) is erased before parsing -- a file containing only type-level statements parses to an empty `Program` body | -| `javascript` | [meriyah](https://github.com/meriyah/meriyah), in-process | [ESTree](https://github.com/estree/estree) `Program` with `loc` position info | -| `python` | Python's standard-library [`ast` module](https://docs.python.org/3/library/ast.html), via the system interpreter | JSON-serialized `ast` nodes: `{ "_type": "Module", "body": [...] }` -- each node carries `_type`, the node's own fields, and `lineno` / `col_offset` positions | -| `ruby` | Ruby's standard-library [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html), via the system interpreter | `Ripper.sexp` nested arrays: `["program", [["command", ...]]]` with `[line, column]` position pairs embedded in token entries | +| Language | Backing parser | Returned shape | +| ------------ | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `typescript` | [meriyah](https://github.com/meriyah/meriyah), in-process, after transpiling TypeScript away with `Bun.Transpiler` | [ESTree](https://github.com/estree/estree) `Program` with `loc` position info. Type-only syntax (`interface`, type aliases, `export type { ... } from`) is erased before parsing -- a file containing only type-level statements parses to an empty `Program` body. `loc` positions refer to the **transpiled output**, not the original `.ts` file -- dropped type-only statements, comments, and blank lines make line numbers drift, so re-locate the construct in the original source (e.g. `ctx.readFile()` plus `indexOf`) before reporting a `line`, or omit `line` entirely; `loc` is source-accurate only for `javascript` | +| `javascript` | [meriyah](https://github.com/meriyah/meriyah), in-process | [ESTree](https://github.com/estree/estree) `Program` with `loc` position info | +| `python` | Python's standard-library [`ast` module](https://docs.python.org/3/library/ast.html), via the system interpreter | JSON-serialized `ast` nodes: `{ "_type": "Module", "body": [...] }` -- each node carries `_type`, the node's own fields, and `lineno` / `col_offset` positions | +| `ruby` | Ruby's standard-library [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html), via the system interpreter | `Ripper.sexp` nested arrays: `["program", [["command", ...]]]` with `[line, column]` position pairs embedded in token entries | See [Structural checks with ctx.ast()](/guides/writing-rules/#structural-checks-with-ctxast) for a complete example rule per language. diff --git a/docs/src/content/docs/guides/writing-rules.mdx b/docs/src/content/docs/guides/writing-rules.mdx index f1c3047e..e08855be 100644 --- a/docs/src/content/docs/guides/writing-rules.mdx +++ b/docs/src/content/docs/guides/writing-rules.mdx @@ -277,7 +277,7 @@ export default { } satisfies RuleSet; ``` -Because TypeScript is transpiled before parsing, `export type { ... } from` re-exports are erased -- a barrel containing only type re-exports parses to an empty `body`, which the `body.length > 0` guard skips. If you also need to flag type-only barrels, combine the AST check with a text check. +Because TypeScript is transpiled before parsing, `export type { ... } from` re-exports are erased -- a barrel containing only type re-exports parses to an empty `body`, which the `body.length > 0` guard skips. If you also need to flag type-only barrels, combine the AST check with a text check. The same transpilation also shifts positions: `loc` line numbers refer to the transpiled text, not your original `.ts` file, so a `"typescript"` rule must re-locate the construct in the original source (for example with `ctx.readFile()` and `indexOf`) before reporting a `line` -- or omit `line`. ### JavaScript: no require() in ES modules @@ -333,6 +333,8 @@ export default { } satisfies RuleSet; ``` +The `loc.start.line` reporting here is valid only because `"javascript"` parses the original source untranspiled -- a `"typescript"` rule must locate the line in the original source instead, since its `loc` refers to the transpiled output. + ### Python: no bare except clauses In the Python `ast` module, an `except:` clause is an `ExceptHandler` node whose `type` field holds the caught exception expression -- a bare `except:` has `"type": null`. Walk the JSON tree for that shape: diff --git a/docs/src/content/docs/nb/guides/writing-rules.mdx b/docs/src/content/docs/nb/guides/writing-rules.mdx index 54058f58..65fb795f 100644 --- a/docs/src/content/docs/nb/guides/writing-rules.mdx +++ b/docs/src/content/docs/nb/guides/writing-rules.mdx @@ -277,7 +277,7 @@ export default { } satisfies RuleSet; ``` -Fordi TypeScript transpileres før parsing, fjernes `export type { ... } from`-re-eksporter -- en barrel som bare inneholder type-re-eksporter parses til en tom `body`, som `body.length > 0`-vakten hopper over. Hvis du også trenger å flagge rene type-barrels, kombiner AST-sjekken med en tekstsjekk. +Fordi TypeScript transpileres før parsing, fjernes `export type { ... } from`-re-eksporter -- en barrel som bare inneholder type-re-eksporter parses til en tom `body`, som `body.length > 0`-vakten hopper over. Hvis du også trenger å flagge rene type-barrels, kombiner AST-sjekken med en tekstsjekk. Den samme transpileringen forskyver også posisjoner: `loc`-linjenumre refererer til den transpilerte teksten, ikke den opprinnelige `.ts`-filen, så en `"typescript"`-regel må finne konstruksjonen på nytt i den opprinnelige kildekoden (for eksempel med `ctx.readFile()` og `indexOf`) før den rapporterer en `line` -- eller utelate `line`. ### JavaScript: ingen require() i ES-moduler @@ -333,6 +333,8 @@ export default { } satisfies RuleSet; ``` +`loc.start.line`-rapporteringen her er gyldig bare fordi `"javascript"` parser den opprinnelige kildekoden utranspilert -- en `"typescript"`-regel må i stedet finne linjen i den opprinnelige kildekoden, siden dens `loc` refererer til det transpilerte resultatet. + ### Python: ingen nakne except-klausuler I Pythons `ast`-modul er en `except:`-klausul en `ExceptHandler`-node der `type`-feltet holder uttrykket for unntaket som fanges -- en naken `except:` har `"type": null`. Gå gjennom JSON-treet etter den formen: diff --git a/docs/src/content/docs/nb/reference/rule-api.mdx b/docs/src/content/docs/nb/reference/rule-api.mdx index 2f679b6b..f20efbf3 100644 --- a/docs/src/content/docs/nb/reference/rule-api.mdx +++ b/docs/src/content/docs/nb/reference/rule-api.mdx @@ -294,12 +294,12 @@ type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; type AstNode = Record | unknown[]; ``` -| Språk | Underliggende parser | Returnert form | -| ------------ | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `typescript` | [meriyah](https://github.com/meriyah/meriyah), i prosessen, etter at TypeScript er transpilert bort med `Bun.Transpiler` | [ESTree](https://github.com/estree/estree) `Program` med `loc`-posisjonsinfo. Typenivå-syntaks (`interface`, typealiaser, `export type { ... } from`) fjernes før parsing -- en fil som bare inneholder setninger på typenivå parses til en tom `Program`-body | -| `javascript` | [meriyah](https://github.com/meriyah/meriyah), i prosessen | [ESTree](https://github.com/estree/estree) `Program` med `loc`-posisjonsinfo | -| `python` | Pythons [`ast`-modul](https://docs.python.org/3/library/ast.html) fra standardbiblioteket, via systemtolken | JSON-serialiserte `ast`-noder: `{ "_type": "Module", "body": [...] }` -- hver node har `_type`, nodens egne felt og posisjonene `lineno` / `col_offset` | -| `ruby` | Rubys [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html) fra standardbiblioteket, via systemtolken | Nestede `Ripper.sexp`-matriser: `["program", [["command", ...]]]` med `[line, column]`-posisjonspar innebygd i token-oppføringene | +| Språk | Underliggende parser | Returnert form | +| ------------ | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `typescript` | [meriyah](https://github.com/meriyah/meriyah), i prosessen, etter at TypeScript er transpilert bort med `Bun.Transpiler` | [ESTree](https://github.com/estree/estree) `Program` med `loc`-posisjonsinfo. Typenivå-syntaks (`interface`, typealiaser, `export type { ... } from`) fjernes før parsing -- en fil som bare inneholder setninger på typenivå parses til en tom `Program`-body. `loc`-posisjoner refererer til det **transpilerte resultatet**, ikke den opprinnelige `.ts`-filen -- fjernede typenivå-setninger, kommentarer og tomme linjer gjør at linjenumrene forskyves, så finn konstruksjonen på nytt i den opprinnelige kildekoden (f.eks. `ctx.readFile()` pluss `indexOf`) før du rapporterer en `line`, eller utelat `line` helt; `loc` er nøyaktig mot kildekoden bare for `javascript` | +| `javascript` | [meriyah](https://github.com/meriyah/meriyah), i prosessen | [ESTree](https://github.com/estree/estree) `Program` med `loc`-posisjonsinfo | +| `python` | Pythons [`ast`-modul](https://docs.python.org/3/library/ast.html) fra standardbiblioteket, via systemtolken | JSON-serialiserte `ast`-noder: `{ "_type": "Module", "body": [...] }` -- hver node har `_type`, nodens egne felt og posisjonene `lineno` / `col_offset` | +| `ruby` | Rubys [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html) fra standardbiblioteket, via systemtolken | Nestede `Ripper.sexp`-matriser: `["program", [["command", ...]]]` med `[line, column]`-posisjonspar innebygd i token-oppføringene | Se [Strukturelle sjekker med ctx.ast()](/guides/writing-rules/#strukturelle-sjekker-med-ctxast) for et komplett eksempel på en regel per språk. diff --git a/docs/src/content/docs/pt-br/guides/writing-rules.mdx b/docs/src/content/docs/pt-br/guides/writing-rules.mdx index 8f90dd03..fa26be20 100644 --- a/docs/src/content/docs/pt-br/guides/writing-rules.mdx +++ b/docs/src/content/docs/pt-br/guides/writing-rules.mdx @@ -277,7 +277,7 @@ export default { } satisfies RuleSet; ``` -Como o TypeScript é transpilado antes do parse, re-exports `export type { ... } from` são apagados -- um barrel contendo apenas re-exports de tipos parseia para um `body` vazio, que a guarda `body.length > 0` ignora. Se você também precisa sinalizar barrels exclusivamente de tipos, combine a verificação de AST com uma verificação textual. +Como o TypeScript é transpilado antes do parse, re-exports `export type { ... } from` são apagados -- um barrel contendo apenas re-exports de tipos parseia para um `body` vazio, que a guarda `body.length > 0` ignora. Se você também precisa sinalizar barrels exclusivamente de tipos, combine a verificação de AST com uma verificação textual. A mesma transpilação também desloca posições: os números de linha em `loc` referem-se ao texto transpilado, não ao seu arquivo `.ts` original, então uma regra `"typescript"` precisa relocalizar a construção no código-fonte original (por exemplo com `ctx.readFile()` e `indexOf`) antes de reportar um `line` -- ou omitir `line`. ### JavaScript: sem require() em módulos ES @@ -333,6 +333,8 @@ export default { } satisfies RuleSet; ``` +O reporte via `loc.start.line` aqui é válido apenas porque `"javascript"` parseia o código-fonte original sem transpilação -- uma regra `"typescript"` precisa localizar a linha no código-fonte original, já que seu `loc` refere-se à saída transpilada. + ### Python: sem cláusulas except vazias No módulo `ast` do Python, uma cláusula `except:` é um nó `ExceptHandler` cujo campo `type` guarda a expressão da exceção capturada -- um `except:` vazio tem `"type": null`. Percorra a árvore JSON em busca desse formato: 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 8331bc72..19d6ca81 100644 --- a/docs/src/content/docs/pt-br/reference/rule-api.mdx +++ b/docs/src/content/docs/pt-br/reference/rule-api.mdx @@ -294,12 +294,12 @@ type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; type AstNode = Record | unknown[]; ``` -| Linguagem | Parser utilizado | Formato retornado | -| ------------ | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `typescript` | [meriyah](https://github.com/meriyah/meriyah), in-process, após remover o TypeScript por transpilação com `Bun.Transpiler` | `Program` [ESTree](https://github.com/estree/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 | -| `javascript` | [meriyah](https://github.com/meriyah/meriyah), in-process | `Program` [ESTree](https://github.com/estree/estree) com informações de posição em `loc` | -| `python` | O [módulo `ast`](https://docs.python.org/3/library/ast.html) da biblioteca padrão do Python, via interpretador do sistema | Nó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` | -| `ruby` | O [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html) da biblioteca padrão do Ruby, via interpretador do sistema | Arrays aninhados de `Ripper.sexp`: `["program", [["command", ...]]]` com pares de posição `[linha, coluna]` embutidos nas entradas de token | +| Linguagem | Parser utilizado | Formato retornado | +| ------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `typescript` | [meriyah](https://github.com/meriyah/meriyah), in-process, após remover o TypeScript por transpilação com `Bun.Transpiler` | `Program` [ESTree](https://github.com/estree/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` | +| `javascript` | [meriyah](https://github.com/meriyah/meriyah), in-process | `Program` [ESTree](https://github.com/estree/estree) com informações de posição em `loc` | +| `python` | O [módulo `ast`](https://docs.python.org/3/library/ast.html) da biblioteca padrão do Python, via interpretador do sistema | Nó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` | +| `ruby` | O [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html) da biblioteca padrão do Ruby, via interpretador do sistema | Arrays 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()](/guides/writing-rules/#verificações-estruturais-com-ctxast) para um exemplo completo de regra por linguagem. diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx index 899c318c..7d8a02b2 100644 --- a/docs/src/content/docs/reference/rule-api.mdx +++ b/docs/src/content/docs/reference/rule-api.mdx @@ -294,12 +294,12 @@ type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; type AstNode = Record | unknown[]; ``` -| Language | Backing parser | Returned shape | -| ------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `typescript` | [meriyah](https://github.com/meriyah/meriyah), in-process, after transpiling TypeScript away with `Bun.Transpiler` | [ESTree](https://github.com/estree/estree) `Program` with `loc` position info. Type-only syntax (`interface`, type aliases, `export type { ... } from`) is erased before parsing -- a file containing only type-level statements parses to an empty `Program` body | -| `javascript` | [meriyah](https://github.com/meriyah/meriyah), in-process | [ESTree](https://github.com/estree/estree) `Program` with `loc` position info | -| `python` | Python's standard-library [`ast` module](https://docs.python.org/3/library/ast.html), via the system interpreter | JSON-serialized `ast` nodes: `{ "_type": "Module", "body": [...] }` -- each node carries `_type`, the node's own fields, and `lineno` / `col_offset` positions | -| `ruby` | Ruby's standard-library [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html), via the system interpreter | `Ripper.sexp` nested arrays: `["program", [["command", ...]]]` with `[line, column]` position pairs embedded in token entries | +| Language | Backing parser | Returned shape | +| ------------ | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `typescript` | [meriyah](https://github.com/meriyah/meriyah), in-process, after transpiling TypeScript away with `Bun.Transpiler` | [ESTree](https://github.com/estree/estree) `Program` with `loc` position info. Type-only syntax (`interface`, type aliases, `export type { ... } from`) is erased before parsing -- a file containing only type-level statements parses to an empty `Program` body. `loc` positions refer to the **transpiled output**, not the original `.ts` file -- dropped type-only statements, comments, and blank lines make line numbers drift, so re-locate the construct in the original source (e.g. `ctx.readFile()` plus `indexOf`) before reporting a `line`, or omit `line` entirely; `loc` is source-accurate only for `javascript` | +| `javascript` | [meriyah](https://github.com/meriyah/meriyah), in-process | [ESTree](https://github.com/estree/estree) `Program` with `loc` position info | +| `python` | Python's standard-library [`ast` module](https://docs.python.org/3/library/ast.html), via the system interpreter | JSON-serialized `ast` nodes: `{ "_type": "Module", "body": [...] }` -- each node carries `_type`, the node's own fields, and `lineno` / `col_offset` positions | +| `ruby` | Ruby's standard-library [`Ripper`](https://docs.ruby-lang.org/en/master/Ripper.html), via the system interpreter | `Ripper.sexp` nested arrays: `["program", [["command", ...]]]` with `[line, column]` position pairs embedded in token entries | See [Structural checks with ctx.ast()](/guides/writing-rules/#structural-checks-with-ctxast) for a complete example rule per language. From b4c29b8893d4667d973fe58a435e3a46c1937361 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 5 Jul 2026 08:34:18 -0300 Subject: [PATCH 10/16] feat(adr): capture ctx.ast hardening invariants in ARCH-022 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth companion rule (python-subprocess-isolated) that statically asserts the Python invocation keeps its -I flag, plus Do/Don't guidance for -I isolation, BOM stripping, and the transpiled-loc caveat — the security/correctness lessons surfaced by the adversarial review, so a future refactor cannot silently drop them. Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- .../adrs/ARCH-022-ast-aware-rule-context.md | 7 +++- .../ARCH-022-ast-aware-rule-context.rules.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.archgate/adrs/ARCH-022-ast-aware-rule-context.md b/.archgate/adrs/ARCH-022-ast-aware-rule-context.md index 1f9b9cea..e867732e 100644 --- a/.archgate/adrs/ARCH-022-ast-aware-rule-context.md +++ b/.archgate/adrs/ARCH-022-ast-aware-rule-context.md @@ -60,6 +60,8 @@ This method dispatches internally based on `language`, and the dispatch mechanis - **DO** reuse the existing `meriyah`-based parser for `"typescript"`/`"javascript"`, factoring the duplicated `parseModule()` call in `rule-scanner.ts` into one shared helper used by both the scanner and `ctx.ast()` - **DO** run the path-safety, language-plausibility, interpreter-availability, and guarded-invocation checks in exactly that order, before any subprocess is spawned, for the `"python"`/`"ruby"` branches - **DO** use `Bun.spawn` with array-based arguments for the Python/Ruby subprocess invocations, per [ARCH-007](./ARCH-007-cross-platform-subprocess-execution.md) +- **DO** run the Python AST subprocess in isolated mode (`python -I -c ...`). Without `-I`, `python -c` places the target project's working directory on `sys.path`, so a hostile project could plant an `ast.py` or `json.py` that executes arbitrary code the moment the serializer imports the standard library. Ruby's load path has not included the cwd since 1.9.2, so no equivalent flag is required for it. +- **DO** strip a leading UTF-8 BOM before parsing in the Python and Ruby serializers (`open(..., encoding="utf-8-sig")` / `File.read(..., mode: "r:bom|utf-8")`). Python's plain `utf-8` codec preserves the BOM as U+FEFF, which `ast.parse` then rejects as a syntax error. - **DO** cache the interpreter-availability probe once per `check` invocation - **DO** throw from `ctx.ast()` on missing interpreter or parse failure, and let it propagate to the existing per-rule `try/catch` in `runner.ts` - **DO** document, in the type signature or accompanying JSDoc, that the returned node shape differs per language @@ -72,6 +74,8 @@ This method dispatches internally based on `language`, and the dispatch mechanis - **DON'T** add `tree-sitter`, `web-tree-sitter`, or any other new production dependency under this decision — Python/Ruby support MUST use only the interpreter's own standard-library AST facility - **DON'T** attempt to normalize Python/Ruby output into an ESTree-like shape as part of this ADR — that is explicitly out of scope - **DON'T** re-probe interpreter availability on every file — cache it per `check` run +- **DON'T** trust `node.loc` line/column numbers for `language: "typescript"`. The TS branch parses `Bun.Transpiler` output, which drops type-only statements, comments, and blank lines, so `loc` refers to the transpiled text, not the original `.ts` file. Re-locate the construct in the original source (e.g. `ctx.readFile()` + `indexOf`) before reporting a line — `loc` is source-accurate only for `"javascript"`, which is parsed directly. The project's own [ARCH-008](./ARCH-008-typed-command-options.md) rules follow this pattern. +- **DON'T** drop the `-I` flag from the Python invocation when refactoring the guarded-invocation step — the `python-subprocess-isolated` companion rule blocks this, and the integration test in `tests/engine/runner-ast.test.ts` asserts a planted shadow `ast.py` cannot run. ## Consequences @@ -103,11 +107,12 @@ This method dispatches internally based on `language`, and the dispatch mechanis ### Automated Enforcement -`ctx.ast()` has shipped, and this ADR now carries `rules: true` with three companion checks in `ARCH-022-ast-aware-rule-context.rules.ts`: +`ctx.ast()` has shipped, and this ADR now carries `rules: true` with four companion checks in `ARCH-022-ast-aware-rule-context.rules.ts`: - **`ast-guardrail-ordering`** — parses `src/engine/runner.ts` via `ctx.ast()` itself (dogfooding the capability this ADR introduces) and verifies the `ast()` method inside `createRuleContext()` invokes the four guardrail markers — `safePath`, `AST_LANGUAGE_EXTENSIONS`, `probeInterpreter`, `runAstSubprocess` — each present and in exactly that order. - **`no-unsanctioned-engine-subprocess`** — flags any `Bun.spawn`/`Bun.spawnSync` call in `src/engine/` outside the sanctioned helpers (`ast-support.ts` for `ctx.ast()`, `git-files.ts` for git), and bans `child_process` imports in the engine entirely, mirroring how `ARCH-007/no-bun-shell` scans for banned subprocess patterns. - **`single-ast-method`** — verifies `RuleContext` (in `src/formats/rules.ts` and the generated shim in `src/helpers/rules-shim.ts`) declares exactly one `ast(path, language)` signature and no per-language variants (`pythonAst()`, `rubyAst()`, etc.). +- **`python-subprocess-isolated`** — asserts the Python branch of the guarded invocation in `src/engine/runner.ts` includes the `-I` isolation flag, so a future refactor cannot silently reintroduce the cwd stdlib-shadowing code-execution vector. ### Manual Enforcement diff --git a/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts b/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts index 5b0e169a..cb8cdb58 100644 --- a/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts +++ b/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts @@ -148,6 +148,38 @@ export default { } }, }, + "python-subprocess-isolated": { + description: + "The Python AST subprocess must run in isolated mode (-I) so a hostile target project cannot shadow stdlib modules on sys.path and execute arbitrary code", + severity: "error", + async check(ctx) { + const file = "src/engine/runner.ts"; + const content = await ctx.readFile(file); + // Locate the python branch of the guarded invocation and confirm the + // argv includes the -I isolation flag before the -c program. Without + // it, `python -c` puts the target project cwd on sys.path, letting a + // planted ast.py/json.py run when the serializer imports them. + const pythonCmd = content.match( + /language === "python"\s*\?\s*\[([^\]]*)\]/u + ); + if (!pythonCmd) { + ctx.report.violation({ + message: `Could not locate the Python invocation argv in ${file} — ARCH-022 requires it to run with -I isolated mode`, + file, + fix: 'Ensure the python branch builds `[interpreter, "-I", "-c", PYTHON_AST_PROGRAM, absPath]`', + }); + return; + } + if (!/["']-I["']/u.test(pythonCmd[1])) { + ctx.report.violation({ + message: + "Python AST subprocess is missing the -I isolation flag — a hostile project could shadow stdlib modules (ast.py/json.py) and execute arbitrary code during `archgate check`", + file, + fix: 'Add "-I" as the first argument before "-c": `[interpreter, "-I", "-c", PYTHON_AST_PROGRAM, absPath]`', + }); + } + }, + }, "single-ast-method": { description: "RuleContext exposes exactly one ast(path, language) method — no per-language variants like pythonAst()/rubyAst()", From 8c59cd869f6cc1131fa8879969bf3416e6033558 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 5 Jul 2026 08:34:34 -0300 Subject: [PATCH 11/16] chore(memory): record interpreter-subprocess parsing gotchas Captures the generalizable kernel from the ctx.ast review (python -I cwd-shadow RCE, BOM codec, transpiled loc drift, Windows py launcher) for future interpreter-subprocess work beyond ctx.ast itself. Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- .../archgate-developer/project_rules_engine_internals.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/agent-memory/archgate-developer/project_rules_engine_internals.md b/.claude/agent-memory/archgate-developer/project_rules_engine_internals.md index 23f2b4f9..bc614c2b 100644 --- a/.claude/agent-memory/archgate-developer/project_rules_engine_internals.md +++ b/.claude/agent-memory/archgate-developer/project_rules_engine_internals.md @@ -9,3 +9,4 @@ metadata: - **Commander hoists parent-known options away from nested subcommands.** If a parent and child subcommand declare the same option (e.g. `session-context ` and ` show` both taking `--max-entries`), commander parses the flag onto the PARENT regardless of argv position — the child's `opts` silently gets `undefined`. Read merged values via `command.optsWithGlobals()` (third action param) and add a regression test through the full `parseAsync` path. Shipped broken in v0.46.0, fixed in PR #448. - **Cross-command I/O sharing: export from the existing command file, don't create a new shared file.** ARCH-002 forbids `console.log` in helpers; ARCH-001/ARCH-016 require a `register*Command` export + docs heading for any new command file. Correct pattern: export the shared functions from the command file that already defines them (e.g. `plugin/install.ts` exports `installForEditor()`/`printManualInstructions()`) and import them elsewhere. Applied in `upgrade.ts`. - **Verify a reviewer sub-agent's ADR citation against the ADR's actual text before blocking.** A haiku review agent flagged "await on a synchronous helper" as an ARCH-012 violation; ARCH-012 only mandates try-catch boundaries/exit codes, and automated rules passed. Re-read the cited ADR's Decision/Do's before accepting a FAIL. A finding citing no ADR (self-labeled "ARCH-NONE") can never block — recurred 2026-07-01 with the same nit. +- **Spawning a language interpreter to parse an untrusted project file is an RCE surface — the specifics are now ADR-governed (ARCH-022), noted here for any FUTURE interpreter-subprocess feature.** (1) `python -c` puts the target project cwd on `sys.path`, so a planted `ast.py`/`json.py` executes when the serializer imports stdlib — always pass `-I` (isolated). Verified: without `-I`, `import ast` resolves to a cwd shadow. (2) Python's plain `utf-8` codec keeps the BOM as U+FEFF and `ast.parse` then errors — read with `utf-8-sig` (`r:bom|utf-8` for Ruby). (3) `meriyah` `loc` after `Bun.Transpiler` is transpiled-relative, NOT original-source lines (type-only/comment/blank lines dropped) — re-locate via `readFile`+`indexOf` for the TS path; JS is parsed untranspiled so its `loc` is accurate. (4) Windows: probe the `py` launcher too (python.org registers it even when PATH opt-in is unchecked). The single sanctioned meriyah call site is `src/engine/js-parser.ts`; interpreter probe/spawn is `src/engine/ast-support.ts`. From 7514ccd76a902bee80b3f40b2a83e10391697728 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 5 Jul 2026 10:00:38 -0300 Subject: [PATCH 12/16] feat(engine): type ctx.ast() return per language via overloads RuleContext.ast() is now overloaded on the language literal so authors get a precise, autocompletable return type instead of the loose AstNode union: ast(p, "typescript"|"javascript") -> EsTreeProgram ast(p, "python") -> PythonAstModule ast(p, "ruby") -> RubyAstNode ast(p, AstLanguage) -> AstNode (fallback) Adds self-contained public shapes (EsTreeNode/EsTreeProgram, PythonAstNode/PythonAstModule, RubyAstNode) to the ambient rules.d.ts so .rules.ts authors get them with zero imports. The internal meriyah return type is renamed MeriyahProgram to avoid clashing with the public EsTreeProgram. Existing ast() tests now access per-language fields without casts, so a regression to the broad union fails typecheck. The guardrail-ordering rule follows the impl into its new const form. Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- .../ARCH-022-ast-aware-rule-context.rules.ts | 37 +++- src/engine/js-parser.ts | 11 +- src/engine/rule-scanner.ts | 6 +- src/engine/runner.ts | 175 +++++++++--------- src/formats/rules.ts | 89 +++++++-- src/helpers/rules-shim.ts | 85 +++++++-- tests/engine/runner-ast.test.ts | 25 +-- 7 files changed, 286 insertions(+), 142 deletions(-) diff --git a/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts b/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts index cb8cdb58..e2531a50 100644 --- a/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts +++ b/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts @@ -56,15 +56,34 @@ export default { let astMethodBody: unknown = null; walk(tree, (n) => { - if (n.type !== "Property") return; - const key = n.key as EsNode | undefined; - const value = n.value as EsNode | undefined; - if ( - key?.type === "Identifier" && - (key as { name?: string }).name === "ast" && - value?.type === "FunctionExpression" - ) { - astMethodBody = value.body; + // Current structure: `const astImpl = async (path, language) => {…}` + // referenced as `ast: astImpl` in the returned object. The `as` + // cast is erased by transpilation before ctx.ast() parses this file, + // so the declarator init is a bare arrow/function expression. + if (n.type === "VariableDeclarator") { + const id = n.id as (EsNode & { name?: string }) | undefined; + const init = n.init as EsNode | undefined; + if ( + id?.name === "astImpl" && + (init?.type === "ArrowFunctionExpression" || + init?.type === "FunctionExpression") + ) { + astMethodBody = init.body; + } + return; + } + // Fallback: inline `ast(path, language) { … }` object method, in + // case the implementation is ever moved back onto the object. + if (n.type === "Property") { + const key = n.key as (EsNode & { name?: string }) | undefined; + const value = n.value as EsNode | undefined; + if ( + key?.name === "ast" && + (value?.type === "FunctionExpression" || + value?.type === "ArrowFunctionExpression") + ) { + astMethodBody = value.body; + } } }); diff --git a/src/engine/js-parser.ts b/src/engine/js-parser.ts index a7adebef..121c9e73 100644 --- a/src/engine/js-parser.ts +++ b/src/engine/js-parser.ts @@ -2,8 +2,13 @@ // Copyright 2026 Archgate import { parseModule, parseScript } from "meriyah"; -/** ESTree Program produced by meriyah's `parseModule`/`parseScript`. */ -export type EsTreeProgram = ReturnType; +/** + * ESTree Program produced by meriyah's `parseModule`/`parseScript` — the + * parser's own richly-typed return. Distinct from the hand-authored, + * self-contained `EsTreeProgram` in `src/formats/rules.ts`, which is the + * public shape `.rules.ts` authors see through the ambient `rules.d.ts`. + */ +export type MeriyahProgram = ReturnType; /** * Parse JavaScript source into an ESTree AST via meriyah. @@ -22,7 +27,7 @@ export type EsTreeProgram = ReturnType; export function parseJsModule( source: string, options?: { jsx?: boolean; sourceType?: "module" | "script" } -): EsTreeProgram { +): MeriyahProgram { if (options?.sourceType === "script") { return parseScript(source, { next: true, diff --git a/src/engine/rule-scanner.ts b/src/engine/rule-scanner.ts index 89deccee..051db779 100644 --- a/src/engine/rule-scanner.ts +++ b/src/engine/rule-scanner.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { parseJsModule, type EsTreeProgram } from "./js-parser"; +import { parseJsModule, type MeriyahProgram } from "./js-parser"; /** * Banned module pattern — matches dangerous Node.js/Bun built-in modules @@ -70,7 +70,7 @@ export function scanRuleSource(source: string): ScanViolation[] { ]; } - let ast: EsTreeProgram; + let ast: MeriyahProgram; try { ast = parseJsModule(js); } catch (err) { @@ -262,7 +262,7 @@ export function scanImportedRuleSource(source: string): ScanViolation[] { ]; } - let ast: EsTreeProgram; + let ast: MeriyahProgram; try { ast = parseJsModule(js); } catch (err) { diff --git a/src/engine/runner.ts b/src/engine/runner.ts index dc755fff..aa57a1a9 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -6,6 +6,7 @@ import { relative, resolve, isAbsolute } from "node:path"; import type { AstLanguage, AstNode, + EsTreeProgram, GrepMatch, RuleContext, RuleReport, @@ -161,6 +162,93 @@ function createRuleContext( }, }; + // ARCH-022: ctx.ast() implementation. Declared as a const cast to the + // overloaded RuleContext["ast"] type so this single implementation + // signature satisfies the language-narrowed public overloads (a single + // broad signature is not directly assignable to the narrow overloads). + // The four guardrails below MUST run in this order before any subprocess. + const astImpl = (async ( + path: string, + language: AstLanguage + ): Promise => { + // Guardrail 1: path safety — same sandbox as readFile/glob. + const absPath = safePath(projectRoot, path); + + // Guardrail 2: language plausibility — refuse to hand a file to an + // interpreter unless its name plausibly matches the requested language. + const lowerPath = path.toLowerCase(); + const basename = lowerPath.split(/[/\\]/u).pop() ?? ""; + const plausible = + AST_LANGUAGE_EXTENSIONS[language].some((ext) => + lowerPath.endsWith(ext) + ) || + (language === "ruby" && RUBY_BASENAMES.has(basename)); + if (!plausible) { + throw new UserError( + `File "${path}" does not look like ${language} (expected ${AST_LANGUAGE_EXTENSIONS[ + language + ].join(", ")}) — refusing to parse` + ); + } + + // In-process branch: TypeScript/JavaScript via the shared meriyah + // parser (js-parser.ts). No subprocess is spawned for these languages. + if (language === "typescript" || language === "javascript") { + const source = await Bun.file(absPath).text(); + try { + if (language === "typescript") { + const loader = lowerPath.endsWith(".tsx") ? "tsx" : "ts"; + const js = new Bun.Transpiler({ loader }).transformSync(source); + return parseJsModule(js) as unknown as EsTreeProgram; + } + // .cjs cannot legally contain import/export in Node — parse it as + // a sloppy-mode script so CommonJS-isms (top-level return, `with`) + // do not fail under module/strict grammar. + return parseJsModule(source, { + jsx: lowerPath.endsWith(".jsx"), + sourceType: lowerPath.endsWith(".cjs") ? "script" : "module", + }) as unknown as EsTreeProgram; + } catch (err) { + throw new Error( + `Failed to parse "${path}" as ${language}: ${parseErrorMessage(err)}` + ); + } + } + + // Guardrail 3: interpreter availability probe, cached per check run. + const candidates = interpreterCandidates(language); + let probe = interpreterCache.get(language); + if (!probe) { + probe = probeInterpreter(candidates); + interpreterCache.set(language, probe); + } + const interpreter = await probe; + if (!interpreter) { + throw new Error( + `${language === "python" ? "Python" : "Ruby"} interpreter not found on PATH (tried: ${candidates.join( + ", " + )}) — ctx.ast("${path}", "${language}") requires it wherever \`archgate check\` runs` + ); + } + + // Guardrail 4: guarded invocation — array args only, path via argv. + // Python runs in isolated mode (-I): without it, `python -c` puts the + // cwd (the target project root) on sys.path, so a hostile project + // could shadow stdlib modules (ast.py, json.py) and execute arbitrary + // code when the serializer imports them. Ruby is safe as-is — its + // load path has not included the cwd since 1.9.2. + const cmd = + language === "python" + ? [interpreter, "-I", "-c", PYTHON_AST_PROGRAM, absPath] + : [interpreter, "-rripper", "-rjson", "-e", RUBY_AST_PROGRAM, absPath]; + const { exitCode, stdout, stderr } = await runAstSubprocess(cmd); + if (exitCode !== 0) { + const detail = stderr.trim() || `exit code ${exitCode}`; + throw new Error(`Failed to parse "${path}" as ${language}: ${detail}`); + } + return parseAstJson(stdout, path, language) as unknown as AstNode; + }) as unknown as RuleContext["ast"]; + return { projectRoot, scopedFiles, @@ -284,92 +372,7 @@ function createRuleContext( }, // ARCH-022: the only sanctioned path from rule code to language tooling. - // The four guardrails below MUST run in this order before any subprocess. - async ast(path: string, language: AstLanguage): Promise { - // Guardrail 1: path safety — same sandbox as readFile/glob. - const absPath = safePath(projectRoot, path); - - // Guardrail 2: language plausibility — refuse to hand a file to an - // interpreter unless its name plausibly matches the requested language. - const lowerPath = path.toLowerCase(); - const basename = lowerPath.split(/[/\\]/u).pop() ?? ""; - const plausible = - AST_LANGUAGE_EXTENSIONS[language].some((ext) => - lowerPath.endsWith(ext) - ) || - (language === "ruby" && RUBY_BASENAMES.has(basename)); - if (!plausible) { - throw new UserError( - `File "${path}" does not look like ${language} (expected ${AST_LANGUAGE_EXTENSIONS[ - language - ].join(", ")}) — refusing to parse` - ); - } - - // In-process branch: TypeScript/JavaScript via the shared meriyah - // parser (js-parser.ts). No subprocess is spawned for these languages. - if (language === "typescript" || language === "javascript") { - const source = await Bun.file(absPath).text(); - try { - if (language === "typescript") { - const loader = lowerPath.endsWith(".tsx") ? "tsx" : "ts"; - const js = new Bun.Transpiler({ loader }).transformSync(source); - return parseJsModule(js) as unknown as AstNode; - } - // .cjs cannot legally contain import/export in Node — parse it as - // a sloppy-mode script so CommonJS-isms (top-level return, `with`) - // do not fail under module/strict grammar. - return parseJsModule(source, { - jsx: lowerPath.endsWith(".jsx"), - sourceType: lowerPath.endsWith(".cjs") ? "script" : "module", - }) as unknown as AstNode; - } catch (err) { - throw new Error( - `Failed to parse "${path}" as ${language}: ${parseErrorMessage(err)}` - ); - } - } - - // Guardrail 3: interpreter availability probe, cached per check run. - const candidates = interpreterCandidates(language); - let probe = interpreterCache.get(language); - if (!probe) { - probe = probeInterpreter(candidates); - interpreterCache.set(language, probe); - } - const interpreter = await probe; - if (!interpreter) { - throw new Error( - `${language === "python" ? "Python" : "Ruby"} interpreter not found on PATH (tried: ${candidates.join( - ", " - )}) — ctx.ast("${path}", "${language}") requires it wherever \`archgate check\` runs` - ); - } - - // Guardrail 4: guarded invocation — array args only, path via argv. - // Python runs in isolated mode (-I): without it, `python -c` puts the - // cwd (the target project root) on sys.path, so a hostile project - // could shadow stdlib modules (ast.py, json.py) and execute arbitrary - // code when the serializer imports them. Ruby is safe as-is — its - // load path has not included the cwd since 1.9.2. - const cmd = - language === "python" - ? [interpreter, "-I", "-c", PYTHON_AST_PROGRAM, absPath] - : [ - interpreter, - "-rripper", - "-rjson", - "-e", - RUBY_AST_PROGRAM, - absPath, - ]; - const { exitCode, stdout, stderr } = await runAstSubprocess(cmd); - if (exitCode !== 0) { - const detail = stderr.trim() || `exit code ${exitCode}`; - throw new Error(`Failed to parse "${path}" as ${language}: ${detail}`); - } - return parseAstJson(stdout, path, language); - }, + ast: astImpl, }; } diff --git a/src/formats/rules.ts b/src/formats/rules.ts index a63af9b9..0b3abf2b 100644 --- a/src/formats/rules.ts +++ b/src/formats/rules.ts @@ -68,22 +68,68 @@ export interface PackageJson { export type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; /** - * Root node returned by `RuleContext.ast()`. - * - * The shape is language-native and deliberately NOT unified across languages - * (see ARCH-022): - * - `"typescript"` / `"javascript"` — ESTree Program (meriyah). TypeScript is - * transpiled before parsing, so type-only syntax is erased from the tree - * AND `loc` positions refer to the transpiled output, not the original - * file — re-locate matches in the original source before reporting line - * numbers. `loc` is source-accurate for `"javascript"`, which parses the - * file directly. - * - `"python"` — the standard `ast` module tree serialized to JSON; each node - * is `{ _type: "Name", ...fields, lineno, col_offset, ... }`. - * - `"ruby"` — `Ripper.sexp` output: nested arrays such as - * `["program", [["command", ...]]]` with `[line, column]` position pairs. + * A node in the ESTree tree returned for `"typescript"`/`"javascript"`. + * `type` is the ESTree node discriminant (e.g. `"ImportDeclaration"`, + * `"CallExpression"`). Only the fields common to every node are typed; the + * rest of each node's grammar is reachable through the index signature — walk + * it against the ESTree spec. Note: for `"typescript"`, `loc` refers to the + * transpiled output (see `ast()`), not the original `.ts` source. */ -export type AstNode = Record | unknown[]; +export interface EsTreeNode { + type: string; + loc?: { + start: { line: number; column: number }; + end: { line: number; column: number }; + } | null; + range?: [number, number]; + [key: string]: unknown; +} + +/** Root ESTree node returned for `"typescript"`/`"javascript"`. */ +export interface EsTreeProgram extends EsTreeNode { + type: "Program"; + sourceType: "module" | "script"; + body: EsTreeNode[]; +} + +/** + * A node in the Python `ast` tree returned for `"python"`, serialized to JSON. + * `_type` is the node class name (e.g. `"FunctionDef"`, `"Call"`, + * `"ExceptHandler"`). Position attributes are present on most nodes. Field + * values are other `PythonAstNode`s, arrays, or primitives, reachable through + * the index signature — walk it against the standard `ast` module's grammar. + */ +export interface PythonAstNode { + _type: string; + lineno?: number; + col_offset?: number; + end_lineno?: number; + end_col_offset?: number; + [key: string]: unknown; +} + +/** Root Python node returned for `"python"` (`_type: "Module"`). */ +export interface PythonAstModule extends PythonAstNode { + _type: "Module"; + body: PythonAstNode[]; +} + +/** + * The Ruby AST returned for `"ruby"` — `Ripper.sexp` output as nested arrays. + * Each node is `[nodeType, ...children]` where `nodeType` is a string tag + * (e.g. `"program"`, `"command"`, `"@ident"`) and children are further + * `RubyAstNode`s, `[line, column]` pairs, strings, or `null`. Ripper's shape + * is deliberately not normalized — walk it against Ripper's own grammar. + */ +export type RubyAstNode = unknown[]; + +/** + * Return type of the language-agnostic `ast()` overload (when `language` is a + * non-literal `AstLanguage`). The shape is language-native and deliberately + * NOT unified across languages (see ARCH-022); prefer calling `ast()` with a + * string literal so the per-language overload narrows this union for you. + */ +export type AstNode = EsTreeProgram | PythonAstModule | RubyAstNode; // --- Rule Context --- @@ -100,14 +146,25 @@ export interface RuleContext { /** * Parse a source file into its language-native AST. * + * The return type is selected by the `language` literal: an + * {@link EsTreeProgram} for `"typescript"`/`"javascript"`, a + * {@link PythonAstModule} for `"python"`, and a {@link RubyAstNode} for + * `"ruby"`. The shapes are language-native and are NOT unified (see + * ARCH-022) — walk each against its own grammar. + * * TypeScript/JavaScript parse in-process. Python and Ruby require the * corresponding interpreter (`python3`/`python`, `ruby`) on PATH wherever * `archgate check` runs — locally and in CI. * * Throws (never returns null) when the file fails to parse or the required * interpreter is missing; the error message distinguishes the two cases. - * The returned node shape differs per language — see {@link AstNode}. */ + ast( + path: string, + language: "typescript" | "javascript" + ): Promise; + ast(path: string, language: "python"): Promise; + ast(path: string, language: "ruby"): Promise; ast(path: string, language: AstLanguage): Promise; report: RuleReport; } diff --git a/src/helpers/rules-shim.ts b/src/helpers/rules-shim.ts index 76c5cdc7..f30e3116 100644 --- a/src/helpers/rules-shim.ts +++ b/src/helpers/rules-shim.ts @@ -73,20 +73,67 @@ declare interface PackageJson { declare type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; /** - * Root node returned by \`RuleContext.ast()\`. The shape is language-native - * and deliberately NOT unified across languages: - * - "typescript" / "javascript" — ESTree Program (meriyah). TypeScript is - * transpiled before parsing, so type-only syntax is erased from the tree - * AND \`loc\` positions refer to the transpiled output, not the original - * file — re-locate matches in the original source before reporting line - * numbers. \`loc\` is source-accurate for "javascript", which parses the - * file directly. - * - "python" — the standard \`ast\` module tree serialized to JSON; each node - * is \`{ _type: "Name", ...fields, lineno, col_offset, ... }\`. - * - "ruby" — \`Ripper.sexp\` output: nested arrays such as - * \`["program", [["command", ...]]]\` with \`[line, column]\` position pairs. + * A node in the ESTree tree returned for "typescript"/"javascript". \`type\` + * is the ESTree node discriminant (e.g. "ImportDeclaration", + * "CallExpression"). Only the fields common to every node are typed; the rest + * of each node's grammar is reachable through the index signature. Note: for + * "typescript", \`loc\` refers to the transpiled output (see \`ast()\`), not + * the original .ts source. */ -declare type AstNode = Record | unknown[]; +declare interface EsTreeNode { + type: string; + loc?: { + start: { line: number; column: number }; + end: { line: number; column: number }; + } | null; + range?: [number, number]; + [key: string]: unknown; +} + +/** Root ESTree node returned for "typescript"/"javascript". */ +declare interface EsTreeProgram extends EsTreeNode { + type: "Program"; + sourceType: "module" | "script"; + body: EsTreeNode[]; +} + +/** + * A node in the Python \`ast\` tree returned for "python", serialized to JSON. + * \`_type\` is the node class name (e.g. "FunctionDef", "Call", + * "ExceptHandler"). Position attributes are present on most nodes. Field + * values are other \`PythonAstNode\`s, arrays, or primitives, reachable + * through the index signature — walk it against the standard \`ast\` grammar. + */ +declare interface PythonAstNode { + _type: string; + lineno?: number; + col_offset?: number; + end_lineno?: number; + end_col_offset?: number; + [key: string]: unknown; +} + +/** Root Python node returned for "python" (\`_type: "Module"\`). */ +declare interface PythonAstModule extends PythonAstNode { + _type: "Module"; + body: PythonAstNode[]; +} + +/** + * The Ruby AST returned for "ruby" — \`Ripper.sexp\` output as nested arrays. + * Each node is \`[nodeType, ...children]\` where \`nodeType\` is a string tag + * (e.g. "program", "command", "@ident") and children are further + * \`RubyAstNode\`s, \`[line, column]\` pairs, strings, or null. Ripper's shape + * is not normalized — walk it against Ripper's own grammar. + */ +declare type RubyAstNode = unknown[]; + +/** + * Return type of the language-agnostic \`ast()\` overload (when \`language\` + * is a non-literal \`AstLanguage\`). Prefer calling \`ast()\` with a string + * literal so the per-language overload narrows this union for you. + */ +declare type AstNode = EsTreeProgram | PythonAstModule | RubyAstNode; declare interface RuleContext { projectRoot: string; @@ -101,14 +148,24 @@ declare interface RuleContext { /** * Parse a source file into its language-native AST. * + * The return type is selected by the \`language\` literal: an + * \`EsTreeProgram\` for "typescript"/"javascript", a \`PythonAstModule\` for + * "python", and a \`RubyAstNode\` for "ruby". The shapes are language-native + * and are NOT unified — walk each against its own grammar. + * * TypeScript/JavaScript parse in-process. Python and Ruby require the * corresponding interpreter (\`python3\`/\`python\`, \`ruby\`) on PATH * wherever \`archgate check\` runs — locally and in CI. * * Throws (never returns null) when the file fails to parse or the required * interpreter is missing; the error message distinguishes the two cases. - * The returned node shape differs per language — see AstNode. */ + ast( + path: string, + language: "typescript" | "javascript" + ): Promise; + ast(path: string, language: "python"): Promise; + ast(path: string, language: "ruby"): Promise; ast(path: string, language: AstLanguage): Promise; report: RuleReport; } diff --git a/tests/engine/runner-ast.test.ts b/tests/engine/runner-ast.test.ts index ca077763..70860613 100644 --- a/tests/engine/runner-ast.test.ts +++ b/tests/engine/runner-ast.test.ts @@ -116,22 +116,20 @@ describe("runChecks ctx.ast()", () => { "no-hello-fn": { description: "Detect a function named hello via the AST", async check(ctx) { - const program = (await ctx.ast("src/app.ts", "typescript")) as { - type: string; - body: Array<{ - type: string; - id?: { name?: string }; - loc?: { start: { line: number } }; - }>; - }; + // No cast: the "typescript" overload narrows to EsTreeProgram, so + // `.sourceType` / `.body` are typed. A regression to the broad + // `AstNode` union would fail this file's typecheck. + const program = await ctx.ast("src/app.ts", "typescript"); + expect(program.sourceType).toBe("module"); bodyTypes = program.body.map((node) => node.type); for (const node of program.body) { + const id = node.id as { name?: string } | undefined; if ( node.type === "FunctionDeclaration" && - node.id?.name === "hello" + id?.name === "hello" ) { ctx.report.violation({ - message: `Function "${node.id.name}" is banned`, + message: `Function "${id.name}" is banned`, file: "src/app.ts", }); } @@ -248,7 +246,10 @@ describe("runChecks ctx.ast()", () => { "no-bare-except": { description: "Disallow bare except: clauses", async check(ctx) { + // No cast: the "python" overload narrows to PythonAstModule, + // so `._type` is typed. const tree = await ctx.ast("src/handler.py", "python"); + expect(tree._type).toBe("Module"); const hits: Array> = []; collectPyNodes( tree, @@ -291,9 +292,11 @@ describe("runChecks ctx.ast()", () => { "no-hello-method": { description: "Detect a method named hello via Ripper sexp", async check(ctx) { + // No cast: the "ruby" overload narrows to RubyAstNode (an + // array), so index access is typed. const sexp = await ctx.ast("src/greeter.rb", "ruby"); expect(Array.isArray(sexp)).toBe(true); - expect((sexp as unknown[])[0]).toBe("program"); + expect(sexp[0]).toBe("program"); if (sexpHasIdent(sexp, "hello")) { ctx.report.violation({ message: 'Method "hello" is banned', From 3b1dc384814edc61ef9e7e68f33a7736dbc02e7a Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 5 Jul 2026 12:13:16 -0300 Subject: [PATCH 13/16] refactor: remove redundant type assertions now that ctx.ast() overloads narrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With per-language overloads returning EsTreeProgram / PythonAstModule / RubyAstNode, the `as { body: ... }` and `as { _type: ... }` casts throughout rules, tests, and docs examples are dead weight. Removes them and replaces local EsNode/EstreeStatement interfaces with the ambient EsTreeNode/PythonAstNode from rules.d.ts, so consumers see how the typed API is intended to be used — cast-free. Walk helpers still cast `unknown` children to the node type; these are inherent to tree traversal and cannot be removed without a polymorphic tree library. Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- .../adrs/ARCH-004-no-barrel-files.rules.ts | 29 ++++-------- .../ARCH-008-typed-command-options.rules.ts | 27 +++++------ .../ARCH-022-ast-aware-rule-context.rules.ts | 20 +++------ docs/public/llms-full.txt | 37 ++++++++------- .../src/content/docs/guides/writing-rules.mdx | 29 ++++++------ .../content/docs/nb/guides/writing-rules.mdx | 29 ++++++------ .../content/docs/nb/reference/rule-api.mdx | 8 ++-- .../docs/pt-br/guides/writing-rules.mdx | 29 ++++++------ .../content/docs/pt-br/reference/rule-api.mdx | 8 ++-- docs/src/content/docs/reference/rule-api.mdx | 8 ++-- tests/engine/runner-ast.test.ts | 45 ++++++++----------- 11 files changed, 115 insertions(+), 154 deletions(-) diff --git a/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts b/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts index b51b7deb..7cd752ea 100644 --- a/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts +++ b/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts @@ -1,14 +1,5 @@ /// -/** - * ESTree statement shape (the subset this rule inspects). ctx.ast() returns - * an untyped AstNode, so top-level statements are narrowed through this. - */ -interface EstreeStatement { - type?: unknown; - declaration?: unknown; -} - /** * A Program body is barrel-shaped when every top-level statement is purely * import/re-export plumbing: @@ -21,14 +12,13 @@ interface EstreeStatement { * class declarations, expression statements — is executable logic, so the * file is not a barrel. */ -function isReExportOnlyBody(body: unknown[]): boolean { +function isReExportOnlyBody(body: EsTreeNode[]): boolean { return body.every((node) => { - const stmt = node as EstreeStatement; - if (stmt.type === "ImportDeclaration") return true; - if (stmt.type === "ExportAllDeclaration") return true; + if (node.type === "ImportDeclaration") return true; + if (node.type === "ExportAllDeclaration") return true; return ( - stmt.type === "ExportNamedDeclaration" && - (stmt.declaration === null || stmt.declaration === undefined) + node.type === "ExportNamedDeclaration" && + (node.declaration === null || node.declaration === undefined) ); }); } @@ -69,7 +59,7 @@ export default { ); const checks = indexFiles.map(async (file) => { - let program: AstNode; + let program: EsTreeProgram; try { program = await ctx.ast(file, "typescript"); } catch { @@ -78,12 +68,9 @@ export default { return; } - const body = (program as { body?: unknown[] }).body; - if (!Array.isArray(body)) return; - const barrel = - body.length > 0 - ? isReExportOnlyBody(body) + program.body.length > 0 + ? isReExportOnlyBody(program.body) : isTypeOnlyBarrel(await ctx.readFile(file)); if (barrel) { diff --git a/.archgate/adrs/ARCH-008-typed-command-options.rules.ts b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts index 16d96230..fd383882 100644 --- a/.archgate/adrs/ARCH-008-typed-command-options.rules.ts +++ b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts @@ -10,11 +10,6 @@ * so formatting, whitespace, and string escaping no longer matter. */ -interface EsNode { - type?: string; - [key: string]: unknown; -} - /** * Description strings that enumerate a fixed set of values, e.g. * "editor integration to configure (claude, cursor, vscode, copilot)" or @@ -31,13 +26,13 @@ const VALUE_TAKING_FLAG = /^--[\w-]+\s+<\w+>/u; const PARSER_IDENTIFIERS = new Set(["parseInt", "parseFloat", "Number"]); /** Depth-first walk over an ESTree-shaped tree. */ -function walk(node: unknown, visit: (n: EsNode) => void): void { +function walk(node: unknown, visit: (n: EsTreeNode) => void): void { if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; } if (!node || typeof node !== "object") return; - const n = node as EsNode; + const n = node as EsTreeNode; if (typeof n.type === "string") visit(n); for (const value of Object.values(n)) { if (value && typeof value === "object") walk(value, visit); @@ -45,24 +40,22 @@ function walk(node: unknown, visit: (n: EsNode) => void): void { } /** Collect the argument lists of every `.option(...)` call in a tree. */ -function findOptionCallArgs(tree: AstNode): EsNode[][] { - const calls: EsNode[][] = []; +function findOptionCallArgs(tree: EsTreeProgram): EsTreeNode[][] { + const calls: EsTreeNode[][] = []; walk(tree, (n) => { if (n.type !== "CallExpression") return; - const callee = n.callee as EsNode | undefined; + const callee = n.callee as EsTreeNode | undefined; if (callee?.type !== "MemberExpression" || callee.computed === true) return; - const property = callee.property as - | (EsNode & { name?: string }) - | undefined; + const property = callee.property as EsTreeNode | undefined; if (property?.type !== "Identifier" || property.name !== "option") return; - calls.push((n.arguments as EsNode[] | undefined) ?? []); + calls.push((n.arguments as EsTreeNode[] | undefined) ?? []); }); return calls; } function isStringLiteral( - node: EsNode | undefined -): node is EsNode & { value: string } { + node: EsTreeNode | undefined +): node is EsTreeNode & { value: string } { return node?.type === "Literal" && typeof node.value === "string"; } @@ -161,7 +154,7 @@ export default { third?.type === "FunctionExpression"; const isParserIdentifier = third?.type === "Identifier" && - PARSER_IDENTIFIERS.has((third as { name?: string }).name ?? ""); + PARSER_IDENTIFIERS.has(String(third.name ?? "")); if (!isFunctionArg && !isParserIdentifier) continue; const flag = args[0]; flagged.push(isStringLiteral(flag) ? flag.value : undefined); diff --git a/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts b/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts index e2531a50..dcfea362 100644 --- a/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts +++ b/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts @@ -22,20 +22,14 @@ const SANCTIONED_SPAWN_FILES = new Set([ "src/engine/git-files.ts", // git subprocess helper, predates ARCH-022 ]); -interface EsNode { - type?: string; - loc?: { start: { line: number; column: number } }; - [key: string]: unknown; -} - /** Depth-first walk over an ESTree-shaped tree. */ -function walk(node: unknown, visit: (n: EsNode) => void): void { +function walk(node: unknown, visit: (n: EsTreeNode) => void): void { if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; } if (!node || typeof node !== "object") return; - const n = node as EsNode; + const n = node as EsTreeNode; if (typeof n.type === "string") visit(n); for (const value of Object.values(n)) { if (value && typeof value === "object") walk(value, visit); @@ -61,8 +55,8 @@ export default { // cast is erased by transpilation before ctx.ast() parses this file, // so the declarator init is a bare arrow/function expression. if (n.type === "VariableDeclarator") { - const id = n.id as (EsNode & { name?: string }) | undefined; - const init = n.init as EsNode | undefined; + const id = n.id as (EsTreeNode & { name?: string }) | undefined; + const init = n.init as EsTreeNode | undefined; if ( id?.name === "astImpl" && (init?.type === "ArrowFunctionExpression" || @@ -75,8 +69,8 @@ export default { // Fallback: inline `ast(path, language) { … }` object method, in // case the implementation is ever moved back onto the object. if (n.type === "Property") { - const key = n.key as (EsNode & { name?: string }) | undefined; - const value = n.value as EsNode | undefined; + const key = n.key as (EsTreeNode & { name?: string }) | undefined; + const value = n.value as EsTreeNode | undefined; if ( key?.name === "ast" && (value?.type === "FunctionExpression" || @@ -101,7 +95,7 @@ export default { const firstSeen = new Map(); walk(astMethodBody, (n) => { if (n.type !== "Identifier" || !n.loc) return; - const name = (n as { name?: string }).name ?? ""; + const name = typeof n.name === "string" ? n.name : ""; if (!GUARDRAIL_SEQUENCE.includes(name) || firstSeen.has(name)) { return; } diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 75b4ae4b..7fc3b1af 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -2875,9 +2875,8 @@ const testFiles = await ctx.glob("tests/**/*.test.ts"); Parse a source file into its language-native AST. Supported languages: `"typescript"`, `"javascript"`, `"python"`, and `"ruby"`. TypeScript and JavaScript parse in-process into an ESTree `Program`; Python and Ruby shell out to the system interpreter's standard-library parser. Throws on parse failure or missing interpreter -- it never returns `null`. ```typescript -const program = (await ctx.ast("src/cli.ts", "typescript")) as { - body: { type: string }[]; -}; +// EsTreeProgram — .body, .sourceType are typed; no cast needed +const program = await ctx.ast("src/cli.ts", "typescript"); ``` See [Structural checks with ctx.ast()](#structural-checks-with-ctxast) below for complete examples, and the [Rule API Reference](/reference/rule-api/#ast) for the returned shape per language. @@ -2944,9 +2943,8 @@ export default { ); const checks = indexFiles.map(async (file) => { - const program = (await ctx.ast(file, "typescript")) as { - body: { type: string; source: { value: string } | null }[]; - }; + // EsTreeProgram — .body is EsTreeNode[]; no cast needed + const program = await ctx.ast(file, "typescript"); const isBarrel = program.body.length > 0 && @@ -2987,17 +2985,16 @@ function findRequireCalls(node: unknown, lines: number[]): void { return; } if (node === null || typeof node !== "object") return; - const record = node as Record; - const callee = record.callee as Record | undefined; + const n = node as EsTreeNode; + const callee = n.callee as EsTreeNode | undefined; if ( - record.type === "CallExpression" && + n.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "require" ) { - const loc = record.loc as { start: { line: number } }; - lines.push(loc.start.line); + if (n.loc) lines.push(n.loc.start.line); } - for (const value of Object.values(record)) findRequireCalls(value, lines); + for (const value of Object.values(n)) findRequireCalls(value, lines); } export default { @@ -3043,11 +3040,11 @@ function findBareExcepts(node: unknown, lines: number[]): void { return; } if (node === null || typeof node !== "object") return; - const record = node as Record; - if (record._type === "ExceptHandler" && record.type === null) { - lines.push(record.lineno as number); + const n = node as PythonAstNode; + if (n._type === "ExceptHandler" && n.type === null) { + if (n.lineno !== undefined) lines.push(n.lineno); } - for (const value of Object.values(record)) findBareExcepts(value, lines); + for (const value of Object.values(n)) findBareExcepts(value, lines); } export default { @@ -5233,9 +5230,11 @@ ast( Parse a source file into its language-native AST. The path is relative to the project root and passes through the same sandbox as `readFile`. TypeScript and JavaScript are parsed in-process; Python and Ruby are parsed by invoking the system interpreter's own standard-library AST facility as a subprocess. The returned tree shape differs per language -- see [AstNode](#astnode). ```typescript -const program = (await ctx.ast("src/cli.ts", "typescript")) as { - body: { type: string }[]; -}; +// EsTreeProgram — .body, .sourceType, and .type are typed +const program = await ctx.ast("src/cli.ts", "typescript"); +for (const node of program.body) { + console.log(node.type); +} ``` `ast()` **throws** on failure -- it never returns `null`: diff --git a/docs/src/content/docs/guides/writing-rules.mdx b/docs/src/content/docs/guides/writing-rules.mdx index e08855be..b37f72df 100644 --- a/docs/src/content/docs/guides/writing-rules.mdx +++ b/docs/src/content/docs/guides/writing-rules.mdx @@ -179,9 +179,8 @@ const testFiles = await ctx.glob("tests/**/*.test.ts"); Parse a source file into its language-native AST. Supported languages: `"typescript"`, `"javascript"`, `"python"`, and `"ruby"`. TypeScript and JavaScript parse in-process into an ESTree `Program`; Python and Ruby shell out to the system interpreter's standard-library parser. Throws on parse failure or missing interpreter -- it never returns `null`. ```typescript -const program = (await ctx.ast("src/cli.ts", "typescript")) as { - body: { type: string }[]; -}; +// EsTreeProgram — .body, .sourceType are typed; no cast needed +const program = await ctx.ast("src/cli.ts", "typescript"); ``` See [Structural checks with ctx.ast()](#structural-checks-with-ctxast) below for complete examples, and the [Rule API Reference](/reference/rule-api/#ast) for the returned shape per language. @@ -249,9 +248,8 @@ export default { ); const checks = indexFiles.map(async (file) => { - const program = (await ctx.ast(file, "typescript")) as { - body: { type: string; source: { value: string } | null }[]; - }; + // EsTreeProgram — .body is EsTreeNode[]; no cast needed + const program = await ctx.ast(file, "typescript"); const isBarrel = program.body.length > 0 && @@ -292,17 +290,16 @@ function findRequireCalls(node: unknown, lines: number[]): void { return; } if (node === null || typeof node !== "object") return; - const record = node as Record; - const callee = record.callee as Record | undefined; + const n = node as EsTreeNode; + const callee = n.callee as EsTreeNode | undefined; if ( - record.type === "CallExpression" && + n.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "require" ) { - const loc = record.loc as { start: { line: number } }; - lines.push(loc.start.line); + if (n.loc) lines.push(n.loc.start.line); } - for (const value of Object.values(record)) findRequireCalls(value, lines); + for (const value of Object.values(n)) findRequireCalls(value, lines); } export default { @@ -348,11 +345,11 @@ function findBareExcepts(node: unknown, lines: number[]): void { return; } if (node === null || typeof node !== "object") return; - const record = node as Record; - if (record._type === "ExceptHandler" && record.type === null) { - lines.push(record.lineno as number); + const n = node as PythonAstNode; + if (n._type === "ExceptHandler" && n.type === null) { + if (n.lineno !== undefined) lines.push(n.lineno); } - for (const value of Object.values(record)) findBareExcepts(value, lines); + for (const value of Object.values(n)) findBareExcepts(value, lines); } export default { diff --git a/docs/src/content/docs/nb/guides/writing-rules.mdx b/docs/src/content/docs/nb/guides/writing-rules.mdx index 65fb795f..642285cb 100644 --- a/docs/src/content/docs/nb/guides/writing-rules.mdx +++ b/docs/src/content/docs/nb/guides/writing-rules.mdx @@ -179,9 +179,8 @@ const testFiles = await ctx.glob("tests/**/*.test.ts"); Parse en kildefil til dens språknative AST. Støttede språk: `"typescript"`, `"javascript"`, `"python"` og `"ruby"`. TypeScript og JavaScript parses i prosessen til en ESTree `Program`; Python og Ruby starter systemtolkens parser fra standardbiblioteket som en underprosess. Kaster ved parsefeil eller manglende tolk -- den returnerer aldri `null`. ```typescript -const program = (await ctx.ast("src/cli.ts", "typescript")) as { - body: { type: string }[]; -}; +// EsTreeProgram — .body, .sourceType are typed; no cast needed +const program = await ctx.ast("src/cli.ts", "typescript"); ``` Se [Strukturelle sjekker med ctx.ast()](#strukturelle-sjekker-med-ctxast) nedenfor for komplette eksempler, og [Regel-API-referansen](/reference/rule-api/#ast) for den returnerte formen per språk. @@ -249,9 +248,8 @@ export default { ); const checks = indexFiles.map(async (file) => { - const program = (await ctx.ast(file, "typescript")) as { - body: { type: string; source: { value: string } | null }[]; - }; + // EsTreeProgram — .body is EsTreeNode[]; no cast needed + const program = await ctx.ast(file, "typescript"); const isBarrel = program.body.length > 0 && @@ -292,17 +290,16 @@ function findRequireCalls(node: unknown, lines: number[]): void { return; } if (node === null || typeof node !== "object") return; - const record = node as Record; - const callee = record.callee as Record | undefined; + const n = node as EsTreeNode; + const callee = n.callee as EsTreeNode | undefined; if ( - record.type === "CallExpression" && + n.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "require" ) { - const loc = record.loc as { start: { line: number } }; - lines.push(loc.start.line); + if (n.loc) lines.push(n.loc.start.line); } - for (const value of Object.values(record)) findRequireCalls(value, lines); + for (const value of Object.values(n)) findRequireCalls(value, lines); } export default { @@ -348,11 +345,11 @@ function findBareExcepts(node: unknown, lines: number[]): void { return; } if (node === null || typeof node !== "object") return; - const record = node as Record; - if (record._type === "ExceptHandler" && record.type === null) { - lines.push(record.lineno as number); + const n = node as PythonAstNode; + if (n._type === "ExceptHandler" && n.type === null) { + if (n.lineno !== undefined) lines.push(n.lineno); } - for (const value of Object.values(record)) findBareExcepts(value, lines); + for (const value of Object.values(n)) findBareExcepts(value, lines); } export default { diff --git a/docs/src/content/docs/nb/reference/rule-api.mdx b/docs/src/content/docs/nb/reference/rule-api.mdx index f20efbf3..6807fbae 100644 --- a/docs/src/content/docs/nb/reference/rule-api.mdx +++ b/docs/src/content/docs/nb/reference/rule-api.mdx @@ -180,9 +180,11 @@ ast( Parse en kildefil til dens språknative AST. Stien er relativ til prosjektroten og går gjennom samme sandkasse som `readFile`. TypeScript og JavaScript parses i prosessen; Python og Ruby parses ved å starte systemtolkens egen AST-fasilitet fra standardbiblioteket som en underprosess. Formen på det returnerte treet varierer per språk -- se [AstNode](#astnode). ```typescript -const program = (await ctx.ast("src/cli.ts", "typescript")) as { - body: { type: string }[]; -}; +// EsTreeProgram — .body, .sourceType, and .type are typed +const program = await ctx.ast("src/cli.ts", "typescript"); +for (const node of program.body) { + console.log(node.type); +} ``` `ast()` **kaster** ved feil -- den returnerer aldri `null`: diff --git a/docs/src/content/docs/pt-br/guides/writing-rules.mdx b/docs/src/content/docs/pt-br/guides/writing-rules.mdx index fa26be20..311a4ef1 100644 --- a/docs/src/content/docs/pt-br/guides/writing-rules.mdx +++ b/docs/src/content/docs/pt-br/guides/writing-rules.mdx @@ -179,9 +179,8 @@ const testFiles = await ctx.glob("tests/**/*.test.ts"); Faz o parse de um arquivo-fonte para sua AST nativa da linguagem. Linguagens suportadas: `"typescript"`, `"javascript"`, `"python"` e `"ruby"`. TypeScript e JavaScript são parseados in-process para um `Program` ESTree; Python e Ruby invocam como subprocesso o parser da biblioteca padrão do interpretador do sistema. Lança uma exceção em caso de falha de parse ou interpretador ausente -- nunca retorna `null`. ```typescript -const program = (await ctx.ast("src/cli.ts", "typescript")) as { - body: { type: string }[]; -}; +// EsTreeProgram — .body, .sourceType are typed; no cast needed +const program = await ctx.ast("src/cli.ts", "typescript"); ``` Veja [Verificações estruturais com ctx.ast()](#verificações-estruturais-com-ctxast) abaixo para exemplos completos, e a [Referência da API de Regras](/reference/rule-api/#ast) para o formato retornado por linguagem. @@ -249,9 +248,8 @@ export default { ); const checks = indexFiles.map(async (file) => { - const program = (await ctx.ast(file, "typescript")) as { - body: { type: string; source: { value: string } | null }[]; - }; + // EsTreeProgram — .body is EsTreeNode[]; no cast needed + const program = await ctx.ast(file, "typescript"); const isBarrel = program.body.length > 0 && @@ -292,17 +290,16 @@ function findRequireCalls(node: unknown, lines: number[]): void { return; } if (node === null || typeof node !== "object") return; - const record = node as Record; - const callee = record.callee as Record | undefined; + const n = node as EsTreeNode; + const callee = n.callee as EsTreeNode | undefined; if ( - record.type === "CallExpression" && + n.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "require" ) { - const loc = record.loc as { start: { line: number } }; - lines.push(loc.start.line); + if (n.loc) lines.push(n.loc.start.line); } - for (const value of Object.values(record)) findRequireCalls(value, lines); + for (const value of Object.values(n)) findRequireCalls(value, lines); } export default { @@ -348,11 +345,11 @@ function findBareExcepts(node: unknown, lines: number[]): void { return; } if (node === null || typeof node !== "object") return; - const record = node as Record; - if (record._type === "ExceptHandler" && record.type === null) { - lines.push(record.lineno as number); + const n = node as PythonAstNode; + if (n._type === "ExceptHandler" && n.type === null) { + if (n.lineno !== undefined) lines.push(n.lineno); } - for (const value of Object.values(record)) findBareExcepts(value, lines); + for (const value of Object.values(n)) findBareExcepts(value, lines); } export default { 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 19d6ca81..1b9aa316 100644 --- a/docs/src/content/docs/pt-br/reference/rule-api.mdx +++ b/docs/src/content/docs/pt-br/reference/rule-api.mdx @@ -180,9 +180,11 @@ ast( 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](#astnode). ```typescript -const program = (await ctx.ast("src/cli.ts", "typescript")) as { - body: { type: string }[]; -}; +// EsTreeProgram — .body, .sourceType, and .type are typed +const program = await ctx.ast("src/cli.ts", "typescript"); +for (const node of program.body) { + console.log(node.type); +} ``` `ast()` **lança uma exceção** em caso de falha -- nunca retorna `null`: diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx index 7d8a02b2..50668ad3 100644 --- a/docs/src/content/docs/reference/rule-api.mdx +++ b/docs/src/content/docs/reference/rule-api.mdx @@ -180,9 +180,11 @@ ast( Parse a source file into its language-native AST. The path is relative to the project root and passes through the same sandbox as `readFile`. TypeScript and JavaScript are parsed in-process; Python and Ruby are parsed by invoking the system interpreter's own standard-library AST facility as a subprocess. The returned tree shape differs per language -- see [AstNode](#astnode). ```typescript -const program = (await ctx.ast("src/cli.ts", "typescript")) as { - body: { type: string }[]; -}; +// EsTreeProgram — .body, .sourceType, and .type are typed +const program = await ctx.ast("src/cli.ts", "typescript"); +for (const node of program.body) { + console.log(node.type); +} ``` `ast()` **throws** on failure -- it never returns `null`: diff --git a/tests/engine/runner-ast.test.ts b/tests/engine/runner-ast.test.ts index 70860613..5fe1e0df 100644 --- a/tests/engine/runner-ast.test.ts +++ b/tests/engine/runner-ast.test.ts @@ -19,7 +19,11 @@ import type { LoadResult } from "../../src/engine/loader"; import { getExitCode } from "../../src/engine/reporter"; import { runChecks } from "../../src/engine/runner"; import type { AdrDocument } from "../../src/formats/adr"; -import type { RuleContext, RuleSet } from "../../src/formats/rules"; +import type { + PythonAstNode, + RuleContext, + RuleSet, +} from "../../src/formats/rules"; // Probe once at load time so interpreter-dependent tests can skipIf cleanly. const pythonInterpreter = await probeInterpreter( @@ -30,17 +34,17 @@ const rubyInterpreter = await probeInterpreter(interpreterCandidates("ruby")); /** Recursively collect Python AST nodes matching a predicate. */ function collectPyNodes( node: unknown, - predicate: (record: Record) => boolean, - hits: Array> + predicate: (n: PythonAstNode) => boolean, + hits: PythonAstNode[] ): void { if (Array.isArray(node)) { for (const item of node) collectPyNodes(item, predicate, hits); return; } if (node && typeof node === "object") { - const record = node as Record; - if (predicate(record)) hits.push(record); - for (const value of Object.values(record)) { + const n = node as PythonAstNode; + if (predicate(n)) hits.push(n); + for (const value of Object.values(n)) { collectPyNodes(value, predicate, hits); } } @@ -250,18 +254,17 @@ describe("runChecks ctx.ast()", () => { // so `._type` is typed. const tree = await ctx.ast("src/handler.py", "python"); expect(tree._type).toBe("Module"); - const hits: Array> = []; + const hits: PythonAstNode[] = []; collectPyNodes( tree, - (record) => - record._type === "ExceptHandler" && record.type === null, + (n) => n._type === "ExceptHandler" && n.type === null, hits ); for (const hit of hits) { ctx.report.violation({ message: "Bare except: clause", file: "src/handler.py", - line: typeof hit.lineno === "number" ? hit.lineno : 0, + line: hit.lineno ?? 0, }); } }, @@ -333,10 +336,7 @@ describe("runChecks ctx.ast()", () => { async check(ctx) { const rakefile = await ctx.ast("Rakefile", "ruby"); const gemfile = await ctx.ast("Gemfile", "ruby"); - roots.push( - (rakefile as unknown[])[0], - (gemfile as unknown[])[0] - ); + roots.push(rakefile[0], gemfile[0]); }, }, }, @@ -370,12 +370,8 @@ describe("runChecks ctx.ast()", () => { "jsx-dispatch": { description: "Parse .tsx as typescript and .jsx as javascript", async check(ctx) { - const tsx = (await ctx.ast("src/App.tsx", "typescript")) as { - type: string; - }; - const jsx = (await ctx.ast("src/widget.jsx", "javascript")) as { - type: string; - }; + const tsx = await ctx.ast("src/App.tsx", "typescript"); + const jsx = await ctx.ast("src/widget.jsx", "javascript"); programTypes.push(tsx.type, jsx.type); }, }, @@ -405,10 +401,7 @@ describe("runChecks ctx.ast()", () => { "cjs-script-mode": { description: "Top-level return is legal in .cjs", async check(ctx) { - const program = (await ctx.ast( - "src/legacy.cjs", - "javascript" - )) as { type: string; sourceType: string }; + const program = await ctx.ast("src/legacy.cjs", "javascript"); cjsSourceType = program.sourceType; }, }, @@ -452,9 +445,7 @@ describe("runChecks ctx.ast()", () => { "shadow-guard": { description: "Parse target.py despite a malicious ast.py", async check(ctx) { - const tree = (await ctx.ast("target.py", "python")) as { - _type: string; - }; + const tree = await ctx.ast("target.py", "python"); treeType = tree._type; }, }, From bb98172ce9f15d3e297b53c7d41d879428694a00 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 5 Jul 2026 12:20:02 -0300 Subject: [PATCH 14/16] chore: drop explanatory comments about type changes Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 3 --- docs/src/content/docs/guides/writing-rules.mdx | 2 -- docs/src/content/docs/nb/guides/writing-rules.mdx | 2 -- docs/src/content/docs/nb/reference/rule-api.mdx | 1 - docs/src/content/docs/pt-br/guides/writing-rules.mdx | 2 -- docs/src/content/docs/pt-br/reference/rule-api.mdx | 1 - docs/src/content/docs/reference/rule-api.mdx | 1 - tests/engine/runner-ast.test.ts | 7 ------- 8 files changed, 19 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 7fc3b1af..35296d19 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -2875,7 +2875,6 @@ const testFiles = await ctx.glob("tests/**/*.test.ts"); Parse a source file into its language-native AST. Supported languages: `"typescript"`, `"javascript"`, `"python"`, and `"ruby"`. TypeScript and JavaScript parse in-process into an ESTree `Program`; Python and Ruby shell out to the system interpreter's standard-library parser. Throws on parse failure or missing interpreter -- it never returns `null`. ```typescript -// EsTreeProgram — .body, .sourceType are typed; no cast needed const program = await ctx.ast("src/cli.ts", "typescript"); ``` @@ -2943,7 +2942,6 @@ export default { ); const checks = indexFiles.map(async (file) => { - // EsTreeProgram — .body is EsTreeNode[]; no cast needed const program = await ctx.ast(file, "typescript"); const isBarrel = @@ -5230,7 +5228,6 @@ ast( Parse a source file into its language-native AST. The path is relative to the project root and passes through the same sandbox as `readFile`. TypeScript and JavaScript are parsed in-process; Python and Ruby are parsed by invoking the system interpreter's own standard-library AST facility as a subprocess. The returned tree shape differs per language -- see [AstNode](#astnode). ```typescript -// EsTreeProgram — .body, .sourceType, and .type are typed const program = await ctx.ast("src/cli.ts", "typescript"); for (const node of program.body) { console.log(node.type); diff --git a/docs/src/content/docs/guides/writing-rules.mdx b/docs/src/content/docs/guides/writing-rules.mdx index b37f72df..aa26bdc4 100644 --- a/docs/src/content/docs/guides/writing-rules.mdx +++ b/docs/src/content/docs/guides/writing-rules.mdx @@ -179,7 +179,6 @@ const testFiles = await ctx.glob("tests/**/*.test.ts"); Parse a source file into its language-native AST. Supported languages: `"typescript"`, `"javascript"`, `"python"`, and `"ruby"`. TypeScript and JavaScript parse in-process into an ESTree `Program`; Python and Ruby shell out to the system interpreter's standard-library parser. Throws on parse failure or missing interpreter -- it never returns `null`. ```typescript -// EsTreeProgram — .body, .sourceType are typed; no cast needed const program = await ctx.ast("src/cli.ts", "typescript"); ``` @@ -248,7 +247,6 @@ export default { ); const checks = indexFiles.map(async (file) => { - // EsTreeProgram — .body is EsTreeNode[]; no cast needed const program = await ctx.ast(file, "typescript"); const isBarrel = diff --git a/docs/src/content/docs/nb/guides/writing-rules.mdx b/docs/src/content/docs/nb/guides/writing-rules.mdx index 642285cb..552b9203 100644 --- a/docs/src/content/docs/nb/guides/writing-rules.mdx +++ b/docs/src/content/docs/nb/guides/writing-rules.mdx @@ -179,7 +179,6 @@ const testFiles = await ctx.glob("tests/**/*.test.ts"); Parse en kildefil til dens språknative AST. Støttede språk: `"typescript"`, `"javascript"`, `"python"` og `"ruby"`. TypeScript og JavaScript parses i prosessen til en ESTree `Program`; Python og Ruby starter systemtolkens parser fra standardbiblioteket som en underprosess. Kaster ved parsefeil eller manglende tolk -- den returnerer aldri `null`. ```typescript -// EsTreeProgram — .body, .sourceType are typed; no cast needed const program = await ctx.ast("src/cli.ts", "typescript"); ``` @@ -248,7 +247,6 @@ export default { ); const checks = indexFiles.map(async (file) => { - // EsTreeProgram — .body is EsTreeNode[]; no cast needed const program = await ctx.ast(file, "typescript"); const isBarrel = diff --git a/docs/src/content/docs/nb/reference/rule-api.mdx b/docs/src/content/docs/nb/reference/rule-api.mdx index 6807fbae..713bef13 100644 --- a/docs/src/content/docs/nb/reference/rule-api.mdx +++ b/docs/src/content/docs/nb/reference/rule-api.mdx @@ -180,7 +180,6 @@ ast( Parse en kildefil til dens språknative AST. Stien er relativ til prosjektroten og går gjennom samme sandkasse som `readFile`. TypeScript og JavaScript parses i prosessen; Python og Ruby parses ved å starte systemtolkens egen AST-fasilitet fra standardbiblioteket som en underprosess. Formen på det returnerte treet varierer per språk -- se [AstNode](#astnode). ```typescript -// EsTreeProgram — .body, .sourceType, and .type are typed const program = await ctx.ast("src/cli.ts", "typescript"); for (const node of program.body) { console.log(node.type); diff --git a/docs/src/content/docs/pt-br/guides/writing-rules.mdx b/docs/src/content/docs/pt-br/guides/writing-rules.mdx index 311a4ef1..c4664c53 100644 --- a/docs/src/content/docs/pt-br/guides/writing-rules.mdx +++ b/docs/src/content/docs/pt-br/guides/writing-rules.mdx @@ -179,7 +179,6 @@ const testFiles = await ctx.glob("tests/**/*.test.ts"); Faz o parse de um arquivo-fonte para sua AST nativa da linguagem. Linguagens suportadas: `"typescript"`, `"javascript"`, `"python"` e `"ruby"`. TypeScript e JavaScript são parseados in-process para um `Program` ESTree; Python e Ruby invocam como subprocesso o parser da biblioteca padrão do interpretador do sistema. Lança uma exceção em caso de falha de parse ou interpretador ausente -- nunca retorna `null`. ```typescript -// EsTreeProgram — .body, .sourceType are typed; no cast needed const program = await ctx.ast("src/cli.ts", "typescript"); ``` @@ -248,7 +247,6 @@ export default { ); const checks = indexFiles.map(async (file) => { - // EsTreeProgram — .body is EsTreeNode[]; no cast needed const program = await ctx.ast(file, "typescript"); const isBarrel = 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 1b9aa316..6be80082 100644 --- a/docs/src/content/docs/pt-br/reference/rule-api.mdx +++ b/docs/src/content/docs/pt-br/reference/rule-api.mdx @@ -180,7 +180,6 @@ ast( 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](#astnode). ```typescript -// EsTreeProgram — .body, .sourceType, and .type are typed const program = await ctx.ast("src/cli.ts", "typescript"); for (const node of program.body) { console.log(node.type); diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx index 50668ad3..4b910a91 100644 --- a/docs/src/content/docs/reference/rule-api.mdx +++ b/docs/src/content/docs/reference/rule-api.mdx @@ -180,7 +180,6 @@ ast( Parse a source file into its language-native AST. The path is relative to the project root and passes through the same sandbox as `readFile`. TypeScript and JavaScript are parsed in-process; Python and Ruby are parsed by invoking the system interpreter's own standard-library AST facility as a subprocess. The returned tree shape differs per language -- see [AstNode](#astnode). ```typescript -// EsTreeProgram — .body, .sourceType, and .type are typed const program = await ctx.ast("src/cli.ts", "typescript"); for (const node of program.body) { console.log(node.type); diff --git a/tests/engine/runner-ast.test.ts b/tests/engine/runner-ast.test.ts index 5fe1e0df..aa2cc080 100644 --- a/tests/engine/runner-ast.test.ts +++ b/tests/engine/runner-ast.test.ts @@ -120,9 +120,6 @@ describe("runChecks ctx.ast()", () => { "no-hello-fn": { description: "Detect a function named hello via the AST", async check(ctx) { - // No cast: the "typescript" overload narrows to EsTreeProgram, so - // `.sourceType` / `.body` are typed. A regression to the broad - // `AstNode` union would fail this file's typecheck. const program = await ctx.ast("src/app.ts", "typescript"); expect(program.sourceType).toBe("module"); bodyTypes = program.body.map((node) => node.type); @@ -250,8 +247,6 @@ describe("runChecks ctx.ast()", () => { "no-bare-except": { description: "Disallow bare except: clauses", async check(ctx) { - // No cast: the "python" overload narrows to PythonAstModule, - // so `._type` is typed. const tree = await ctx.ast("src/handler.py", "python"); expect(tree._type).toBe("Module"); const hits: PythonAstNode[] = []; @@ -295,8 +290,6 @@ describe("runChecks ctx.ast()", () => { "no-hello-method": { description: "Detect a method named hello via Ripper sexp", async check(ctx) { - // No cast: the "ruby" overload narrows to RubyAstNode (an - // array), so index access is typed. const sexp = await ctx.ast("src/greeter.rb", "ruby"); expect(Array.isArray(sexp)).toBe(true); expect(sexp[0]).toBe("program"); From 1ede26be4da7ad38d82e7967a9cc7bfa90e77f92 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 5 Jul 2026 13:03:49 -0300 Subject: [PATCH 15/16] fix: address PR review findings - probeInterpreter: add 5s timeout so a stalled binary (e.g. macOS Xcode dialog on python3) cannot hang the cached probe indefinitely - runAstSubprocess: await proc.exited after kill on timeout so the subprocess is confirmed dead before throwing - ARCH-008 rules: catch per-file parse errors so one unparseable file does not abort the entire rule via Promise.all rejection - ARCH-004 barrel fallback: split on newlines instead of semicolons so a semicolon inside a module specifier string does not break the type-only barrel heuristic Claude-Session: https://claude.ai/code/session_012tjbzRdRBG1SLVdXvTA9dX Signed-off-by: Rhuan Barreto --- .../adrs/ARCH-004-no-barrel-files.rules.ts | 10 +++---- .../ARCH-008-typed-command-options.rules.ts | 17 ++++++++---- src/engine/ast-support.ts | 26 ++++++++++++++++--- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts b/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts index 7cd752ea..c97e45d8 100644 --- a/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts +++ b/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts @@ -40,12 +40,12 @@ function isTypeOnlyBarrel(source: string): boolean { if (stripped === "") return false; - const statements = stripped - .split(";") - .map((s) => s.trim()) - .filter((s) => s !== ""); + const lines = stripped + .split("\n") + .map((l) => l.trim()) + .filter((l) => l !== "" && l !== ";"); - return statements.every((s) => /^(?:import|export)\b/u.test(s)); + return lines.every((l) => /^(?:import|export)\b/u.test(l)); } export default { diff --git a/.archgate/adrs/ARCH-008-typed-command-options.rules.ts b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts index fd383882..a7a5e6e1 100644 --- a/.archgate/adrs/ARCH-008-typed-command-options.rules.ts +++ b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts @@ -98,10 +98,12 @@ export default { const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts")); await Promise.all( files.map(async (file) => { - const tree = await ctx.ast(file, "typescript"); - // Flag .option(flag, description, ...) calls — at least two - // string literal args (a trailing default value or parser must - // not exempt the call) — whose description enumerates choices. + let tree: EsTreeProgram; + try { + tree = await ctx.ast(file, "typescript"); + } catch { + return; + } const flagged: string[] = []; for (const args of findOptionCallArgs(tree)) { if (args.length < 2) continue; @@ -140,7 +142,12 @@ export default { const files = ctx.scopedFiles.filter((f) => !f.endsWith("index.ts")); await Promise.all( files.map(async (file) => { - const tree = await ctx.ast(file, "typescript"); + let tree: EsTreeProgram; + try { + tree = await ctx.ast(file, "typescript"); + } catch { + return; + } // Flag .option(flag, description, parser) calls whose third // argument is a function expression or a bare global parser // identifier (parseInt/parseFloat/Number). Literal defaults diff --git a/src/engine/ast-support.ts b/src/engine/ast-support.ts index e1642686..dd9b1db8 100644 --- a/src/engine/ast-support.ts +++ b/src/engine/ast-support.ts @@ -15,6 +15,9 @@ import { isWindows } from "../helpers/platform"; /** Hard cap on a single AST parser subprocess, well under the rule timeout. */ export const AST_SUBPROCESS_TIMEOUT_MS = 15_000; +/** Timeout for the interpreter availability probe (shorter than a real parse). */ +const PROBE_TIMEOUT_MS = 5_000; + /** * Guardrail 2 (language plausibility): extensions accepted per language. * Checked before any interpreter is invoked on the file. @@ -114,12 +117,26 @@ export async function probeInterpreter( stdout: "pipe", stderr: "pipe", }); + let probeTimer: ReturnType | undefined; + const probeTimeout = new Promise<"timeout">((resolve) => { + probeTimer = setTimeout(() => resolve("timeout"), PROBE_TIMEOUT_MS); + }); // oxlint-disable-next-line no-await-in-loop -- candidates probed in priority order - const [exitCode, version] = await Promise.all([ + const raceResult = await Promise.race([ proc.exited, - new Response(proc.stdout).text(), - ]); - if (exitCode === 0) { + probeTimeout, + ]).finally(() => { + if (probeTimer) clearTimeout(probeTimer); + }); + if (raceResult === "timeout") { + proc.kill(); + // oxlint-disable-next-line no-await-in-loop -- must confirm kill before trying next candidate + await proc.exited; + continue; + } + // oxlint-disable-next-line no-await-in-loop -- drain stdout only for the winning candidate + const version = await new Response(proc.stdout).text(); + if (raceResult === 0) { logDebug( `ctx.ast interpreter probe: ${candidate} -> ${version.trim()}` ); @@ -158,6 +175,7 @@ export async function runAstSubprocess( }); if (result === "timeout") { proc.kill(); + await proc.exited; throw new Error( `AST parser subprocess timed out after ${AST_SUBPROCESS_TIMEOUT_MS}ms` ); From 5e2dd8a513cb0e82218ef3624cc51fac9cd4b004 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 5 Jul 2026 23:15:56 -0300 Subject: [PATCH 16/16] fix(rules): handle multi-line type-only re-exports in barrel detection Track brace depth in isTypeOnlyBarrel so continuation lines inside destructured export type { A, B, } blocks are recognized as part of the enclosing import/export statement, not as separate non-barrel lines. Claude-Session: https://claude.ai/code/session_01H7XPPVhN1t6HzH8F3xN4DP Signed-off-by: Rhuan Barreto --- .../adrs/ARCH-004-no-barrel-files.rules.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts b/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts index c97e45d8..cde2a248 100644 --- a/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts +++ b/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts @@ -31,6 +31,10 @@ function isReExportOnlyBody(body: EsTreeNode[]): boolean { * the source: strip comments and blank space, then require every remaining * statement to start with `import` or `export`. A comment-only/empty file * is not a barrel. + * + * Handles multi-line statements (e.g. `export type {\n A,\n} from ...`) + * by tracking brace depth — continuation lines inside `{ }` are part of + * the enclosing import/export, not new statements. */ function isTypeOnlyBarrel(source: string): boolean { const stripped = source @@ -45,7 +49,20 @@ function isTypeOnlyBarrel(source: string): boolean { .map((l) => l.trim()) .filter((l) => l !== "" && l !== ";"); - return lines.every((l) => /^(?:import|export)\b/u.test(l)); + if (lines.length === 0) return false; + + let braceDepth = 0; + for (const line of lines) { + if (braceDepth === 0 && !/^(?:import|export)\b/u.test(line)) { + return false; + } + for (const ch of line) { + if (ch === "{") braceDepth++; + else if (ch === "}") braceDepth--; + } + } + + return true; } export default {