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}` + ); + } + }); +});