diff --git a/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts b/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts
index a5bfe85b..cde2a248 100644
--- a/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts
+++ b/.archgate/adrs/ARCH-004-no-barrel-files.rules.ts
@@ -1,40 +1,68 @@
///
/**
- * Determines whether a file is a barrel (re-export-only index.ts).
+ * 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
+function isReExportOnlyBody(body: EsTreeNode[]): boolean {
+ return body.every((node) => {
+ if (node.type === "ImportDeclaration") return true;
+ if (node.type === "ExportAllDeclaration") return true;
+ return (
+ node.type === "ExportNamedDeclaration" &&
+ (node.declaration === null || node.declaration === undefined)
+ );
+ });
+}
+
+/**
+ * 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.
+ *
+ * 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
+ .replaceAll(/\/\*[\s\S]*?\*\//gu, "")
+ .replaceAll(/\/\/[^\n]*/gu, "")
+ .trim();
+
+ if (stripped === "") return false;
+
+ const lines = stripped
.split("\n")
.map((l) => l.trim())
- .filter(
- (l) =>
- l !== "" &&
- !l.startsWith("//") &&
- !l.startsWith("/*") &&
- !l.startsWith("*")
- );
+ .filter((l) => l !== "" && l !== ";");
if (lines.length === 0) return false;
- 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 === "};"
- );
+ 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 {
@@ -48,8 +76,21 @@ export default {
);
const checks = indexFiles.map(async (file) => {
- const content = await ctx.readFile(file);
- if (isBarrelFile(content)) {
+ let program: EsTreeProgram;
+ 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 barrel =
+ program.body.length > 0
+ ? isReExportOnlyBody(program.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..a7a5e6e1 100644
--- a/.archgate/adrs/ARCH-008-typed-command-options.rules.ts
+++ b/.archgate/adrs/ARCH-008-typed-command-options.rules.ts
@@ -1,5 +1,93 @@
///
+/**
+ * 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.
+ */
+
+/**
+ * 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: 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 EsTreeNode;
+ 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: EsTreeProgram): EsTreeNode[][] {
+ const calls: EsTreeNode[][] = [];
+ walk(tree, (n) => {
+ if (n.type !== "CallExpression") return;
+ const callee = n.callee as EsTreeNode | undefined;
+ if (callee?.type !== "MemberExpression" || callee.computed === true) return;
+ const property = callee.property as EsTreeNode | undefined;
+ if (property?.type !== "Identifier" || property.name !== "option") return;
+ calls.push((n.arguments as EsTreeNode[] | undefined) ?? []);
+ });
+ return calls;
+}
+
+function isStringLiteral(
+ node: EsTreeNode | undefined
+): node is EsTreeNode & { 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 +96,42 @@ 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) => {
+ 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;
+ 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 +140,49 @@ 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) => {
+ 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
+ // (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(String(third.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()",
- });
- }
- }
},
},
},
diff --git a/.archgate/adrs/ARCH-022-ast-aware-rule-context.md b/.archgate/adrs/ARCH-022-ast-aware-rule-context.md
index 593a4716..e867732e 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
@@ -33,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.
@@ -56,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
@@ -68,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
@@ -99,7 +107,12 @@ 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 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
new file mode 100644
index 00000000..dcfea362
--- /dev/null
+++ b/.archgate/adrs/ARCH-022-ast-aware-rule-context.rules.ts
@@ -0,0 +1,229 @@
+///
+
+/**
+ * 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
+]);
+
+/** Depth-first walk over an ESTree-shaped tree. */
+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 EsTreeNode;
+ 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) => {
+ // 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 (EsTreeNode & { name?: string }) | undefined;
+ const init = n.init as EsTreeNode | 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 (EsTreeNode & { name?: string }) | undefined;
+ const value = n.value as EsTreeNode | undefined;
+ if (
+ key?.name === "ast" &&
+ (value?.type === "FunctionExpression" ||
+ value?.type === "ArrowFunctionExpression")
+ ) {
+ 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 = typeof n.name === "string" ? n.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",
+ });
+ }
+ },
+ },
+ "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()",
+ 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;
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`.
diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt
index e6705596..35296d19 100644
--- a/docs/public/llms-full.txt
+++ b/docs/public/llms-full.txt
@@ -2870,6 +2870,16 @@ 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");
+```
+
+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 +2910,222 @@ 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");
+
+ 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. 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
+
+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 n = node as EsTreeNode;
+ const callee = n.callee as EsTreeNode | undefined;
+ if (
+ n.type === "CallExpression" &&
+ callee?.type === "Identifier" &&
+ callee.name === "require"
+ ) {
+ if (n.loc) lines.push(n.loc.start.line);
+ }
+ for (const value of Object.values(n)) 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;
+```
+
+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:
+
+```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 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(n)) 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 +5113,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 +5216,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");
+for (const node of program.body) {
+ console.log(node.type);
+}
+```
+
+`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 +5333,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. `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.
+
+---
+
## Severity
```typescript
diff --git a/docs/src/content/docs/guides/writing-rules.mdx b/docs/src/content/docs/guides/writing-rules.mdx
index 3ce4696b..aa26bdc4 100644
--- a/docs/src/content/docs/guides/writing-rules.mdx
+++ b/docs/src/content/docs/guides/writing-rules.mdx
@@ -174,6 +174,16 @@ 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");
+```
+
+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 +214,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");
+
+ 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. 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
+
+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 n = node as EsTreeNode;
+ const callee = n.callee as EsTreeNode | undefined;
+ if (
+ n.type === "CallExpression" &&
+ callee?.type === "Identifier" &&
+ callee.name === "require"
+ ) {
+ if (n.loc) lines.push(n.loc.start.line);
+ }
+ for (const value of Object.values(n)) 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;
+```
+
+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:
+
+```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 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(n)) 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..552b9203 100644
--- a/docs/src/content/docs/nb/guides/writing-rules.mdx
+++ b/docs/src/content/docs/nb/guides/writing-rules.mdx
@@ -174,6 +174,16 @@ 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");
+```
+
+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 +214,223 @@ 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");
+
+ 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. 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
+
+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 n = node as EsTreeNode;
+ const callee = n.callee as EsTreeNode | undefined;
+ if (
+ n.type === "CallExpression" &&
+ callee?.type === "Identifier" &&
+ callee.name === "require"
+ ) {
+ if (n.loc) lines.push(n.loc.start.line);
+ }
+ for (const value of Object.values(n)) 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;
+```
+
+`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:
+
+```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 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(n)) 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..713bef13 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,36 @@ 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");
+for (const node of program.body) {
+ console.log(node.type);
+}
+```
+
+`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 +286,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. `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.
+
+---
+
## 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..c4664c53 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,16 @@ 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");
+```
+
+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 +214,223 @@ 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");
+
+ 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. 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
+
+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 n = node as EsTreeNode;
+ const callee = n.callee as EsTreeNode | undefined;
+ if (
+ n.type === "CallExpression" &&
+ callee?.type === "Identifier" &&
+ callee.name === "require"
+ ) {
+ if (n.loc) lines.push(n.loc.start.line);
+ }
+ for (const value of Object.values(n)) 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;
+```
+
+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:
+
+```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 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(n)) 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..6be80082 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,36 @@ 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");
+for (const node of program.body) {
+ console.log(node.type);
+}
+```
+
+`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 +286,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. 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.
+
+---
+
## Severity
```typescript
diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx
index cff22121..4b910a91 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,36 @@ 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");
+for (const node of program.body) {
+ console.log(node.type);
+}
+```
+
+`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 +286,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. `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.
+
+---
+
## Severity
```typescript
diff --git a/src/engine/ast-support.ts b/src/engine/ast-support.ts
new file mode 100644
index 00000000..dd9b1db8
--- /dev/null
+++ b/src/engine/ast-support.ts
@@ -0,0 +1,213 @@
+// 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;
+
+/** 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.
+ */
+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-sig") 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], mode: "r:bom|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()). 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", "py"] : ["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",
+ });
+ 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 raceResult = await Promise.race([
+ proc.exited,
+ 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()}`
+ );
+ 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();
+ await proc.exited;
+ 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..121c9e73
--- /dev/null
+++ b/src/engine/js-parser.ts
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 Archgate
+import { parseModule, parseScript } from "meriyah";
+
+/**
+ * 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.
+ *
+ * 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; sourceType?: "module" | "script" }
+): MeriyahProgram {
+ if (options?.sourceType === "script") {
+ return parseScript(source, {
+ next: true,
+ loc: true,
+ globalReturn: true,
+ ...(options?.jsx ? { jsx: true } : {}),
+ });
+ }
+ 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..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 { parseModule } from "meriyah";
+import { parseJsModule, type MeriyahProgram } 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: MeriyahProgram;
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: MeriyahProgram;
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..aa57a1a9 100644
--- a/src/engine/runner.ts
+++ b/src/engine/runner.ts
@@ -4,6 +4,9 @@ import { lstatSync } from "node:fs";
import { relative, resolve, isAbsolute } from "node:path";
import type {
+ AstLanguage,
+ AstNode,
+ EsTreeProgram,
GrepMatch,
RuleContext,
RuleReport,
@@ -11,12 +14,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 +147,8 @@ function createRuleContext(
adrId: string,
ruleId: string,
violations: ViolationDetail[],
- trackedFiles: Set | null
+ trackedFiles: Set | null,
+ interpreterCache: Map>
): RuleContext {
const report: RuleReport = {
violation(detail) {
@@ -146,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,
@@ -267,6 +370,9 @@ function createRuleContext(
const absPath = safePath(projectRoot, path);
return Bun.file(absPath).json();
},
+
+ // ARCH-022: the only sanctioned path from rule code to language tooling.
+ ast: astImpl,
};
}
@@ -316,6 +422,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 +462,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..0b3abf2b 100644
--- a/src/formats/rules.ts
+++ b/src/formats/rules.ts
@@ -62,6 +62,75 @@ export interface PackageJson {
[key: string]: unknown;
}
+// --- AST ---
+
+/** Languages supported by `RuleContext.ast()`. */
+export type AstLanguage = "typescript" | "javascript" | "python" | "ruby";
+
+/**
+ * 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 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 ---
export interface RuleContext {
@@ -74,6 +143,29 @@ 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.
+ *
+ * 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.
+ */
+ 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 982b8446..f30e3116 100644
--- a/src/helpers/rules-shim.ts
+++ b/src/helpers/rules-shim.ts
@@ -69,6 +69,72 @@ declare interface PackageJson {
[key: string]: unknown;
}
+/** Languages supported by \`RuleContext.ast()\`. */
+declare type AstLanguage = "typescript" | "javascript" | "python" | "ruby";
+
+/**
+ * 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 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;
scopedFiles: string[];
@@ -79,6 +145,28 @@ 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.
+ *
+ * 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.
+ */
+ 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/ast-support.test.ts b/tests/engine/ast-support.test.ts
new file mode 100644
index 00000000..470100cd
--- /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", "py"]
+ : ["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..aa2cc080
--- /dev/null
+++ b/tests/engine/runner-ast.test.ts
@@ -0,0 +1,495 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 Archgate
+import { describe, expect, test, beforeEach, afterEach } from "bun:test";
+import {
+ existsSync,
+ 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 {
+ PythonAstNode,
+ RuleContext,
+ 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: (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 n = node as PythonAstNode;
+ if (predicate(n)) hits.push(n);
+ for (const value of Object.values(n)) {
+ 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");
+ 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" &&
+ id?.name === "hello"
+ ) {
+ ctx.report.violation({
+ message: `Function "${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");
+ expect(tree._type).toBe("Module");
+ const hits: PythonAstNode[] = [];
+ collectPyNodes(
+ tree,
+ (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: 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[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);
+ }
+ );
+
+ 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[0], gemfile[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");
+ const jsx = await ctx.ast("src/widget.jsx", "javascript");
+ 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");
+ 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");
+ 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}`
+ );
+ }
+ });
+});