Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/public/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5364,6 +5364,8 @@ interface AstOptions {

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).

Parse results are cached for the lifetime of a single `archgate check` run, keyed on `(path, language, rev, comments)`: repeated -- even concurrent -- identical calls across rules cost one parse (one interpreter spawn for Python/Ruby), and a failed parse rethrows the same error to every caller. Treat the returned tree as read-only -- it may be shared with other rules.

```typescript
const program = await ctx.ast("src/cli.ts", "typescript");
for (const node of program.body) {
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/nb/reference/rule-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ interface AstOptions {

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).

Parseresultater caches gjennom én enkelt `archgate check`-kjøring, med nøkkel `(path, language, rev, comments)`: gjentatte -- til og med samtidige -- identiske kall på tvers av regler koster én parse (én tolkoppstart for Python/Ruby), og en mislykket parse kaster den samme feilen på nytt til alle kallere. Behandle det returnerte treet som skrivebeskyttet -- det kan være delt med andre regler.

```typescript
const program = await ctx.ast("src/cli.ts", "typescript");
for (const node of program.body) {
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/pt-br/reference/rule-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ interface AstOptions {

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).

Os resultados de parse são cacheados durante uma única execução de `archgate check`, com chave `(path, language, rev, comments)`: chamadas idênticas repetidas -- até mesmo concorrentes -- entre regras custam um único parse (uma única inicialização do interpretador para Python/Ruby), e um parse que falhou relança o mesmo erro para todos os chamadores. Trate a árvore retornada como somente leitura -- ela pode ser compartilhada com outras regras.

```typescript
const program = await ctx.ast("src/cli.ts", "typescript");
for (const node of program.body) {
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/rule-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ interface AstOptions {

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).

Parse results are cached for the lifetime of a single `archgate check` run, keyed on `(path, language, rev, comments)`: repeated -- even concurrent -- identical calls across rules cost one parse (one interpreter spawn for Python/Ruby), and a failed parse rethrows the same error to every caller. Treat the returned tree as read-only -- it may be shared with other rules.

```typescript
const program = await ctx.ast("src/cli.ts", "typescript");
for (const node of program.body) {
Expand Down
19 changes: 19 additions & 0 deletions src/engine/ast-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,25 @@ export function finalizeAstResult(
return tree;
}

/**
* Cache key for a per-run `ctx.ast()` parse (see `RunCaches.astResults` in
* runner.ts): the full tuple that determines the parse output, NUL-joined —
* NUL cannot appear in a path, so distinct tuples can never collide.
*/
export function astCacheKey(
absPath: string,
language: AstLanguage,
useBase: boolean,
wantComments: boolean
): string {
return [
absPath,
language,
useBase ? "base" : "working-tree",
wantComments ? "comments" : "no-comments",
].join("\u0000");
}

/** Extract a readable message from Bun.Transpiler/meriyah parse errors. */
export function parseErrorMessage(err: unknown): string {
if (err instanceof AggregateError && err.errors.length > 0) {
Expand Down
197 changes: 120 additions & 77 deletions src/engine/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { relative, resolve, isAbsolute } from "node:path";

import type {
AstLanguage,
AstNode,
AstOptions,
EsTreeProgram,
GrepMatch,
Expand All @@ -22,6 +23,7 @@ import {
PYTHON_AST_WITH_COMMENTS_PROGRAM,
RUBY_AST_PROGRAM,
RUBY_BASENAMES,
astCacheKey,
commentsUnsupportedError,
finalizeAstResult,
implausibleLanguageError,
Expand Down Expand Up @@ -104,16 +106,19 @@ const RULE_TIMEOUT_MS = 30_000;
* without these caches, 40+ rules each repeat identical filesystem work.
*
* Values are promises so concurrent rules share in-flight work instead of
* racing to duplicate it. Only immutable results are cached: glob results
* are copied on return, file contents are strings. `readJSON` is deliberately
* NOT cached — rules receive a mutable object, and sharing one instance
* would leak mutations between rules.
* racing to duplicate it. Glob results are copied on return, file contents
* are immutable strings. AST results are cached as shared trees — rules must
* treat them as read-only. `readJSON` is deliberately NOT cached — rules
* receive a mutable object, and sharing one instance would leak mutations
* between rules.
*/
interface RunCaches {
/** Glob results keyed by `tracked:`/`all:` + pattern. */
globResults: Map<string, Promise<string[]>>;
/** File contents keyed by absolute path. */
fileText: Map<string, Promise<string>>;
/** ctx.ast() parses keyed by the NUL-joined (absPath, language, rev, comments) tuple. */
astResults: Map<string, Promise<AstNode>>;
}

export interface RuleResult {
Expand Down Expand Up @@ -207,6 +212,7 @@ function createRuleContext(
language: "ruby",
opts?: AstOptions
): Promise<RubyAstNode>;
// oxlint-disable-next-line require-await -- async keeps guardrail failures as rejections, never sync throws
async function astImpl(
path: string,
language: AstLanguage,
Expand Down Expand Up @@ -242,81 +248,114 @@ function createRuleContext(
throw commentsUnsupportedError(language, path);
}

// 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 = useBase
? await readBaseSourceOrThrow(projectRoot, baseRev, relPath, path)
: await cachedFileText(absPath);
try {
return parseTsOrJsSource(language, path, source, wantComments);
} catch (err) {
throw new Error(
`Failed to parse "${path}" as ${language}: ${parseErrorMessage(err)}`
);
/**
* The uncached parse: TS/JS in-process, Python/Ruby via guardrails 3–4.
* Errors below are CACHED (see the astResults lookup), so they
* interpolate the normalized `relPath` — never the raw `path` —
* otherwise a cached rejection would carry the first caller's path
* spelling (e.g. "src/./a.py") into every later caller's error, since
* aliased spellings resolve to the same cache entry.
*/
async function parseUncached(): Promise<AstNode> {
// 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 = useBase
? await readBaseSourceOrThrow(projectRoot, baseRev, relPath, relPath)
: await cachedFileText(absPath);
try {
// Meriyah's Program is ESTree-shaped but lacks the index signature.
// `path` is safe here: it only picks the parse mode by extension.
const tree = parseTsOrJsSource(language, path, source, wantComments);
return tree as unknown as EsTreeProgram;
} catch (err) {
throw new Error(
`Failed to parse "${relPath}" 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 interpreterNotFoundError(language, candidates, path);
}
// 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 interpreterNotFoundError(language, candidates, relPath);
}

// For { rev: "base" }, the interpreter serializers read a file path from
// argv, but the base content is not on disk — materialize it to a throwaway
// temp file and hand that path to the same, unchanged program (and the same
// `-I` isolation). Cleaned up in `finally` regardless of outcome.
const { sourcePath, cleanup } = await materializeAstInput({
useBase,
absPath,
ext: language === "python" ? ".py" : ".rb",
projectRoot,
baseRev,
relPath,
displayPath: relPath,
});

// For { rev: "base" }, the interpreter serializers read a file path from
// argv, but the base content is not on disk — materialize it to a throwaway
// temp file and hand that path to the same, unchanged program (and the same
// `-I` isolation). Cleaned up in `finally` regardless of outcome.
const { sourcePath, cleanup } = await materializeAstInput({
useBase,
absPath,
ext: language === "python" ? ".py" : ".rb",
projectRoot,
baseRev,
relPath,
displayPath: path,
});

try {
// 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 pyProgram = wantComments
? PYTHON_AST_WITH_COMMENTS_PROGRAM
: PYTHON_AST_PROGRAM;
const cmd =
language === "python"
? [interpreter, "-I", "-c", pyProgram, sourcePath]
: [
interpreter,
"-rripper",
"-rjson",
"-e",
RUBY_AST_PROGRAM,
sourcePath,
];
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}`);
try {
// 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 pyProgram = wantComments
? PYTHON_AST_WITH_COMMENTS_PROGRAM
: PYTHON_AST_PROGRAM;
const cmd =
language === "python"
? [interpreter, "-I", "-c", pyProgram, sourcePath]
: [
interpreter,
"-rripper",
"-rjson",
"-e",
RUBY_AST_PROGRAM,
sourcePath,
];
const { exitCode, stdout, stderr } = await runAstSubprocess(cmd);
if (exitCode !== 0) {
const detail = stderr.trim() || `exit code ${exitCode}`;
throw new Error(
`Failed to parse "${relPath}" as ${language}: ${detail}`
);
}
return finalizeAstResult(
parseAstJson(stdout, relPath, language),
language,
wantComments
) as AstNode;
} finally {
cleanup?.();
}
return finalizeAstResult(
parseAstJson(stdout, path, language),
language,
wantComments
);
} finally {
cleanup?.();
}

// Per-run parse cache, mirroring cachedGlob/cachedFileText: keyed on the
// full tuple that determines the output, NUL-joined (NUL cannot appear in
// a path, so distinct tuples never collide). The PROMISE is cached, so
// concurrent identical calls share one in-flight parse/subprocess spawn.
// Rejected promises stay cached — a deliberate decision: ctx.ast() is
// fail-closed (ARCH-022), so every rule touching the same input fails
// fast with the identical error instead of re-paying the spawn. The
// cheap argument-validation guardrails above (path safety, language
// plausibility, feature guard) still run per call, before this lookup,
// preserving ARCH-022's guardrail ordering on cache hits too.
const cacheKey = astCacheKey(absPath, language, useBase, wantComments);
let hit = caches.astResults.get(cacheKey);
if (!hit) {
hit = parseUncached();
caches.astResults.set(cacheKey, hit);
}
return hit;
}

return {
Expand Down Expand Up @@ -474,9 +513,13 @@ export async function runChecks(
// invocation — shared across every ADR and rule in this run.
const interpreterCache = new Map<string, Promise<string | null>>();

// Per-run glob/file-text caches shared across all rule contexts — rules
// overwhelmingly repeat the same globs and reads (see RunCaches).
const caches: RunCaches = { globResults: new Map(), fileText: new Map() };
// Per-run glob/file-text/AST caches shared across all rule contexts — rules
// overwhelmingly repeat the same globs, reads, and parses (see RunCaches).
const caches: RunCaches = {
globResults: new Map(),
fileText: new Map(),
astResults: new Map(),
};

// Run ADRs in parallel
const adrResults = await Promise.allSettled(
Expand Down
54 changes: 27 additions & 27 deletions tests/engine/runner-ast-base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,37 +406,37 @@ describe("runChecks ctx.fileAtBase() / ctx.ast({ rev: 'base' })", () => {
// away in the outer scope (TS treats a nested-closure write to a plain
// `let` as never happening).
const seen: { doc?: boolean; real?: boolean } = {};
const loaded = makeLoadedAdr({
rules: {
r: {
description: "python doc-only",
async check(ctx) {
const baseTree = await ctx.ast("src/calc.py", "python", {
rev: "base",
});
const headTree = await ctx.ast("src/calc.py", "python");
// A doc-only rule strips docstrings (their value legitimately
// changed) and compares the rest, position-insensitively.
seen.doc =
JSON.stringify(pyStructure(stripDocstrings(baseTree))) ===
JSON.stringify(pyStructure(stripDocstrings(headTree)));

// A real change to the working tree body.
await Bun.write(
join(dir, "src/calc.py"),
"def add(a, b):\n return a - b\n"
);
const realTree = await ctx.ast("src/calc.py", "python");
seen.real =
JSON.stringify(pyStructure(stripDocstrings(baseTree))) ===
JSON.stringify(pyStructure(stripDocstrings(realTree)));
const compareRule = (key: "doc" | "real") =>
makeLoadedAdr({
rules: {
r: {
description: `python ${key} comparison`,
async check(ctx) {
const baseTree = await ctx.ast("src/calc.py", "python", {
rev: "base",
});
const headTree = await ctx.ast("src/calc.py", "python");
// A doc-only rule strips docstrings (their value legitimately
// changed) and compares the rest, position-insensitively.
seen[key] =
JSON.stringify(pyStructure(stripDocstrings(baseTree))) ===
JSON.stringify(pyStructure(stripDocstrings(headTree)));
},
},
},
},
});
});

await runChecks(dir, [loaded], { base: "HEAD" });
await runChecks(dir, [compareRule("doc")], { base: "HEAD" });
expect(seen.doc).toBe(true);

// A real change to the working tree body. Parsed in a SEPARATE run:
// AST parses are cached per check invocation, so a mid-run edit is
// deliberately not observable within the same runChecks call.
await Bun.write(
join(dir, "src/calc.py"),
"def add(a, b):\n return a - b\n"
);
await runChecks(dir, [compareRule("real")], { base: "HEAD" });
expect(seen.real).toBe(false);
}
);
Expand Down
Loading
Loading