From a68ace482eb45277b063950c81ffcefca3e36314 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 16 Jul 2026 18:06:30 -0300 Subject: [PATCH 1/4] feat(engine): support { comments: true } for ctx.ast() Ruby MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #484 Mirror Python's two-pass approach: RUBY_AST_WITH_COMMENTS_PROGRAM runs Ripper.sexp for the tree plus a second Ripper.lex pass for comments, emitting the same {_tree, comments} envelope the Python with-comments serializer uses. # comments become type "line" tokens (leading # stripped, newline chomped); each =begin/=end region becomes ONE type "block" token with the marker lines stripped and loc spanning =begin through =end. A Ripper.lex failure on otherwise-parseable source degrades to an empty comment list instead of failing the parse. Ruby's tree is an array, so the comments array rides on the returned sexp as a non-index property (typed via the new RubyAstProgram root type, mirrored in the rules shim). The early commentsUnsupportedError throw in runner.ts is removed and the helper deleted — Ruby was its last remaining case. Docs updated in all three locales; llms-full.txt regenerated. Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 6 +- .../content/docs/nb/reference/rule-api.mdx | 6 +- .../content/docs/pt-br/reference/rule-api.mdx | 6 +- docs/src/content/docs/reference/rule-api.mdx | 6 +- src/engine/ast-support.ts | 82 ++++++++++--- src/engine/runner.ts | 26 ++-- src/formats/rules.ts | 29 ++++- src/helpers/rules-shim.ts | 25 +++- tests/engine/ast-support.test.ts | 116 ++++++++++++++++++ tests/engine/runner-ast-comments.test.ts | 83 +++++++++++-- 10 files changed, 315 insertions(+), 70 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 43046691..af43ab49 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -5395,7 +5395,7 @@ Reach for `fileAtBase()` first when you need to detect the no-base or added-file ##### Collecting comments (`{ comments: true }`) -With `{ comments: true }`, the returned tree carries a `comments` array -- structured comment data for comment-governance rules, in place of line-by-line regex. Supported for `typescript`, `javascript`, and `python`; requesting it for `ruby` throws. +With `{ comments: true }`, the returned tree carries a `comments` array -- structured comment data for comment-governance rules, in place of line-by-line regex. Supported for all four languages. For `ruby`, the returned tree is an array (`Ripper.sexp` output), so the `comments` array rides on it as a non-index property. ```typescript interface CommentToken { @@ -5422,7 +5422,7 @@ for (const comment of tree.comments ?? []) { } ``` -Comment positions are accurate against the **original source**, even for TypeScript. This is a deliberate advantage over the tree's own `loc`, which is transpiled-relative for TypeScript (see [AstNode](#astnode)): comments are scanned from the pre-transpile source, so their `loc` never drifts. Python comments are always `type: "line"` (`#`) -- Python has no block comments, and `"""` docstrings are string expressions in the tree, not comments. The TypeScript/JavaScript scanner is string- and template-literal-aware, but does not track regular-expression literals, so a comment delimiter inside a regex literal is a known blind spot. +Comment positions are accurate against the **original source**, even for TypeScript. This is a deliberate advantage over the tree's own `loc`, which is transpiled-relative for TypeScript (see [AstNode](#astnode)): comments are scanned from the pre-transpile source, so their `loc` never drifts. Python comments are always `type: "line"` (`#`) -- Python has no block comments, and `"""` docstrings are string expressions in the tree, not comments. Ruby `#` comments are `type: "line"`; each `=begin`/`=end` documentation block is a single `type: "block"` token whose `value` is the inner content (the `=begin`/`=end` marker lines stripped, analogous to `/* */` delimiter stripping) and whose `loc` spans the `=begin` line through the `=end` line. For Python and Ruby, comment extraction is a second pass over the same source (`tokenize` / `Ripper.lex`); a tokenizer failure on otherwise-parseable source degrades to an empty comment list rather than failing the parse. The TypeScript/JavaScript scanner is string- and template-literal-aware, but does not track regular-expression literals, so a comment delimiter inside a regex literal is a known blind spot. ##### Failure behavior @@ -5536,7 +5536,7 @@ type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; type AstNode = Record | unknown[]; ``` -When parsed with [`{ comments: true }`](#ast), the root node also carries a `comments: CommentToken[]` array (`typescript`, `javascript`, and `python` only). It is absent otherwise. +When parsed with [`{ comments: true }`](#ast), the root node also carries a `comments: CommentToken[]` array (all four languages; for `ruby` it is a non-index property on the root sexp array). It is absent otherwise. | Language | Backing parser | Returned shape | | ------------ | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/src/content/docs/nb/reference/rule-api.mdx b/docs/src/content/docs/nb/reference/rule-api.mdx index 853d3cbf..cef41d82 100644 --- a/docs/src/content/docs/nb/reference/rule-api.mdx +++ b/docs/src/content/docs/nb/reference/rule-api.mdx @@ -241,7 +241,7 @@ Bruk `fileAtBase()` først når du trenger å oppdage tilfellene uten basis elle ##### Samle inn kommentarer (`{ comments: true }`) -Med `{ comments: true }` bærer det returnerte treet en `comments`-matrise -- strukturert kommentardata for kommentarstyringsregler, i stedet for linje-for-linje regulære uttrykk. Støttet for `typescript`, `javascript` og `python`; å be om det for `ruby` kaster. +Med `{ comments: true }` bærer det returnerte treet en `comments`-matrise -- strukturert kommentardata for kommentarstyringsregler, i stedet for linje-for-linje regulære uttrykk. Støttet for alle fire språk. For `ruby` er det returnerte treet en matrise (`Ripper.sexp`-utdata), så `comments`-matrisen ligger på den som en ikke-indeksert egenskap. ```typescript interface CommentToken { @@ -268,7 +268,7 @@ for (const comment of tree.comments ?? []) { } ``` -Kommentarposisjoner er nøyaktige mot **originalkilden**, selv for TypeScript. Dette er en bevisst fordel fremfor treets egen `loc`, som er transpilert-relativ for TypeScript (se [AstNode](#astnode)): kommentarer skannes fra kildekoden før transpilering, så deres `loc` driver aldri. Python-kommentarer er alltid `type: "line"` (`#`) -- Python har ingen blokkommentarer, og `"""`-dokstrenger er strengeuttrykk i treet, ikke kommentarer. TypeScript/JavaScript-skanneren er streng- og malstreng-bevisst, men sporer ikke regulært-uttrykk-literaler, så et kommentarskilletegn inne i en regex-literal er et kjent blindpunkt. +Kommentarposisjoner er nøyaktige mot **originalkilden**, selv for TypeScript. Dette er en bevisst fordel fremfor treets egen `loc`, som er transpilert-relativ for TypeScript (se [AstNode](#astnode)): kommentarer skannes fra kildekoden før transpilering, så deres `loc` driver aldri. Python-kommentarer er alltid `type: "line"` (`#`) -- Python har ingen blokkommentarer, og `"""`-dokstrenger er strengeuttrykk i treet, ikke kommentarer. Ruby `#`-kommentarer er `type: "line"`; hver `=begin`/`=end`-dokumentasjonsblokk er ett enkelt `type: "block"`-token der `value` er det indre innholdet (markørlinjene `=begin`/`=end` er fjernet, tilsvarende fjerningen av `/* */`-skilletegn) og der `loc` spenner fra `=begin`-linjen til og med `=end`-linjen. For Python og Ruby er kommentaruthentingen et eget pass over samme kilde (`tokenize` / `Ripper.lex`); en tokeniseringsfeil på ellers parsbar kode degraderer til en tom kommentarliste i stedet for å feile hele parsingen. TypeScript/JavaScript-skanneren er streng- og malstreng-bevisst, men sporer ikke regulært-uttrykk-literaler, så et kommentarskilletegn inne i en regex-literal er et kjent blindpunkt. ##### Feiloppførsel @@ -383,7 +383,7 @@ type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; type AstNode = Record | unknown[]; ``` -Når parset med [`{ comments: true }`](#ast), bærer rotnoden også en `comments: CommentToken[]`-matrise (kun `typescript`, `javascript` og `python`). Den er fraværende ellers. +Når parset med [`{ comments: true }`](#ast), bærer rotnoden også en `comments: CommentToken[]`-matrise (alle fire språk; for `ruby` er den en ikke-indeksert egenskap på rot-sexp-matrisen). Den er fraværende ellers. | Språk | Underliggende parser | Returnert form | | ------------ | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 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 72d1fcee..d4047f53 100644 --- a/docs/src/content/docs/pt-br/reference/rule-api.mdx +++ b/docs/src/content/docs/pt-br/reference/rule-api.mdx @@ -241,7 +241,7 @@ Recorra a `fileAtBase()` primeiro quando você precisar detectar os casos de sem ##### Coletando comentários (`{ comments: true }`) -Com `{ comments: true }`, a árvore retornada carrega um array `comments` -- dados estruturados de comentário para regras de governança de comentários, no lugar de regex linha a linha. Suportado para `typescript`, `javascript` e `python`; solicitar isso para `ruby` lança uma exceção. +Com `{ comments: true }`, a árvore retornada carrega um array `comments` -- dados estruturados de comentário para regras de governança de comentários, no lugar de regex linha a linha. Suportado para as quatro linguagens. Para `ruby`, a árvore retornada é um array (saída de `Ripper.sexp`), então o array `comments` fica anexado a ela como uma propriedade não indexada. ```typescript interface CommentToken { @@ -268,7 +268,7 @@ for (const comment of tree.comments ?? []) { } ``` -As posições dos comentários são precisas em relação ao **código-fonte original**, mesmo para TypeScript. Essa é uma vantagem deliberada em relação ao `loc` da própria árvore, que é relativo ao transpilado para TypeScript (veja [AstNode](#astnode)): os comentários são varridos a partir do código-fonte antes da transpilação, então seu `loc` nunca diverge. Comentários em Python são sempre `type: "line"` (`#`) -- Python não tem comentários de bloco, e docstrings `"""` são expressões de string na árvore, não comentários. O scanner de TypeScript/JavaScript reconhece literais de string e de template, mas não rastreia literais de expressão regular, então um delimitador de comentário dentro de um literal regex é um ponto cego conhecido. +As posições dos comentários são precisas em relação ao **código-fonte original**, mesmo para TypeScript. Essa é uma vantagem deliberada em relação ao `loc` da própria árvore, que é relativo ao transpilado para TypeScript (veja [AstNode](#astnode)): os comentários são varridos a partir do código-fonte antes da transpilação, então seu `loc` nunca diverge. Comentários em Python são sempre `type: "line"` (`#`) -- Python não tem comentários de bloco, e docstrings `"""` são expressões de string na árvore, não comentários. Comentários `#` em Ruby são `type: "line"`; cada bloco de documentação `=begin`/`=end` é um único token `type: "block"` cujo `value` é o conteúdo interno (as linhas marcadoras `=begin`/`=end` são removidas, de forma análoga à remoção dos delimitadores `/* */`) e cujo `loc` vai da linha do `=begin` até a linha do `=end`. Para Python e Ruby, a extração de comentários é uma segunda passada sobre o mesmo código-fonte (`tokenize` / `Ripper.lex`); uma falha do tokenizador em código que parseia normalmente degrada para uma lista de comentários vazia em vez de falhar o parse. O scanner de TypeScript/JavaScript reconhece literais de string e de template, mas não rastreia literais de expressão regular, então um delimitador de comentário dentro de um literal regex é um ponto cego conhecido. ##### Comportamento de falha @@ -383,7 +383,7 @@ type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; type AstNode = Record | unknown[]; ``` -Quando parseado com [`{ comments: true }`](#ast), o nó raiz também carrega um array `comments: CommentToken[]` (apenas `typescript`, `javascript` e `python`). Ausente nos demais casos. +Quando parseado com [`{ comments: true }`](#ast), o nó raiz também carrega um array `comments: CommentToken[]` (as quatro linguagens; para `ruby` ele é uma propriedade não indexada no array sexp raiz). Ausente nos demais casos. | Linguagem | Parser utilizado | Formato retornado | | ------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx index fe0938c2..eaa127a5 100644 --- a/docs/src/content/docs/reference/rule-api.mdx +++ b/docs/src/content/docs/reference/rule-api.mdx @@ -241,7 +241,7 @@ Reach for `fileAtBase()` first when you need to detect the no-base or added-file ##### Collecting comments (`{ comments: true }`) -With `{ comments: true }`, the returned tree carries a `comments` array -- structured comment data for comment-governance rules, in place of line-by-line regex. Supported for `typescript`, `javascript`, and `python`; requesting it for `ruby` throws. +With `{ comments: true }`, the returned tree carries a `comments` array -- structured comment data for comment-governance rules, in place of line-by-line regex. Supported for all four languages. For `ruby`, the returned tree is an array (`Ripper.sexp` output), so the `comments` array rides on it as a non-index property. ```typescript interface CommentToken { @@ -268,7 +268,7 @@ for (const comment of tree.comments ?? []) { } ``` -Comment positions are accurate against the **original source**, even for TypeScript. This is a deliberate advantage over the tree's own `loc`, which is transpiled-relative for TypeScript (see [AstNode](#astnode)): comments are scanned from the pre-transpile source, so their `loc` never drifts. Python comments are always `type: "line"` (`#`) -- Python has no block comments, and `"""` docstrings are string expressions in the tree, not comments. The TypeScript/JavaScript scanner is string- and template-literal-aware, but does not track regular-expression literals, so a comment delimiter inside a regex literal is a known blind spot. +Comment positions are accurate against the **original source**, even for TypeScript. This is a deliberate advantage over the tree's own `loc`, which is transpiled-relative for TypeScript (see [AstNode](#astnode)): comments are scanned from the pre-transpile source, so their `loc` never drifts. Python comments are always `type: "line"` (`#`) -- Python has no block comments, and `"""` docstrings are string expressions in the tree, not comments. Ruby `#` comments are `type: "line"`; each `=begin`/`=end` documentation block is a single `type: "block"` token whose `value` is the inner content (the `=begin`/`=end` marker lines stripped, analogous to `/* */` delimiter stripping) and whose `loc` spans the `=begin` line through the `=end` line. For Python and Ruby, comment extraction is a second pass over the same source (`tokenize` / `Ripper.lex`); a tokenizer failure on otherwise-parseable source degrades to an empty comment list rather than failing the parse. The TypeScript/JavaScript scanner is string- and template-literal-aware, but does not track regular-expression literals, so a comment delimiter inside a regex literal is a known blind spot. ##### Failure behavior @@ -383,7 +383,7 @@ type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; type AstNode = Record | unknown[]; ``` -When parsed with [`{ comments: true }`](#ast), the root node also carries a `comments: CommentToken[]` array (`typescript`, `javascript`, and `python` only). It is absent otherwise. +When parsed with [`{ comments: true }`](#ast), the root node also carries a `comments: CommentToken[]` array (all four languages; for `ruby` it is a non-index property on the root sexp array). It is absent otherwise. | Language | Backing parser | Returned shape | | ------------ | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/src/engine/ast-support.ts b/src/engine/ast-support.ts index 7a32a8ca..e74aeaa9 100644 --- a/src/engine/ast-support.ts +++ b/src/engine/ast-support.ts @@ -31,16 +31,6 @@ export function interpreterNotFoundError( ); } -/** `{ comments: true }` requested for a language that does not support it yet. */ -export function commentsUnsupportedError( - language: AstLanguage, - path: string -): UserError { - return new UserError( - `ctx.ast("${path}", "${language}", { comments: true }) is not supported yet — comments are available for typescript, javascript, and python` - ); -} - /** * Support code for `ctx.ast()` (ARCH-022). Definitions live here to keep * `runner.ts` focused; the mandated four-step guardrail ordering (path @@ -154,6 +144,62 @@ end puts JSON.generate(sexp, max_nesting: false) `; +/** + * Serializer used for Ruby `{ comments: true }`: prints the same + * `{"_tree", "comments"}` envelope as the Python with-comments program, with + * comments from a second `Ripper.lex` pass (`Ripper.sexp` carries none). + * `#` comments become `type: "line"` tokens (`#` stripped, newline chomped); + * each `=begin`/`=end` region becomes ONE `type: "block"` token whose value is + * the inner content (marker lines stripped, like TS/JS stripping the + * delimiters) and whose loc spans the `=begin` line through the `=end` line. + * Lex errors on otherwise-parseable source degrade to an empty comment list + * rather than failing the parse, matching Python's tokenizer-error fallback. + */ +export const RUBY_AST_WITH_COMMENTS_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 +comments = [] +begin + embdoc = nil + Ripper.lex(source).each do |(line, col), event, tok, _state| + case event + when :on_comment + comments << { + type: "line", + value: tok.sub(/\\A#/, "").chomp, + loc: { + start: { line: line, column: col }, + end: { line: line, column: col + tok.chomp.length }, + }, + } + when :on_embdoc_beg + embdoc = { line: line, col: col, value: +"" } + when :on_embdoc + embdoc[:value] << tok unless embdoc.nil? + when :on_embdoc_end + unless embdoc.nil? + comments << { + type: "block", + value: embdoc[:value].chomp, + loc: { + start: { line: embdoc[:line], column: embdoc[:col] }, + end: { line: line, column: col + tok.chomp.length }, + }, + } + embdoc = nil + end + end + end +rescue StandardError + comments = [] +end +puts JSON.generate({ _tree: sexp, comments: comments }, 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`), @@ -372,10 +418,11 @@ export async function materializeAstInput(args: { } /** - * Fold the `{ comments: true }` Python subprocess output back into the shape - * `ctx.ast()` promises. That serializer prints `{ _tree, comments }`; unwrap it - * to the module tree with `comments` attached, so the Python return shape - * matches the ESTree one (a tree carrying a `comments` array). For every other + * Fold the `{ comments: true }` Python/Ruby subprocess output back into the + * shape `ctx.ast()` promises. Those serializers print `{ _tree, comments }`; + * unwrap it to the tree with `comments` attached, so the return shape matches + * the ESTree one (a tree carrying a `comments` array). Ruby's tree is an + * array, so `comments` rides on it as a non-index property. For every other * case the subprocess output is already the tree — pass it through untouched. */ export function finalizeAstResult( @@ -383,12 +430,13 @@ export function finalizeAstResult( language: string, wantComments: boolean ): Record | unknown[] { - if (language !== "python" || !wantComments || Array.isArray(parsed)) { + const hasEnvelope = language === "python" || language === "ruby"; + if (!hasEnvelope || !wantComments || Array.isArray(parsed)) { return parsed; } - const tree = parsed._tree as Record | undefined; + const tree = parsed._tree as Record | unknown[] | undefined; if (!tree) return parsed; - tree.comments = parsed.comments; + (tree as { comments?: unknown }).comments = parsed.comments; return tree; } diff --git a/src/engine/runner.ts b/src/engine/runner.ts index ff630bed..a14d607a 100644 --- a/src/engine/runner.ts +++ b/src/engine/runner.ts @@ -9,7 +9,7 @@ import type { EsTreeProgram, GrepMatch, PythonAstModule, - RubyAstNode, + RubyAstProgram, RuleContext, RuleReport, ViolationDetail, @@ -21,8 +21,8 @@ import { PYTHON_AST_PROGRAM, PYTHON_AST_WITH_COMMENTS_PROGRAM, RUBY_AST_PROGRAM, + RUBY_AST_WITH_COMMENTS_PROGRAM, RUBY_BASENAMES, - commentsUnsupportedError, finalizeAstResult, implausibleLanguageError, interpreterCandidates, @@ -206,7 +206,7 @@ function createRuleContext( path: string, language: "ruby", opts?: AstOptions - ): Promise; + ): Promise; async function astImpl( path: string, language: AstLanguage, @@ -234,14 +234,6 @@ function createRuleContext( throw implausibleLanguageError(language, path); } - // Feature guard (after the language guardrails, before any interpreter - // work): comments are opt-in and Ruby's serializer does not carry them yet. - // Ordered here so an implausible language/file combination fails on the - // plausibility guardrail first, per ARCH-022's guardrail ordering. - if (wantComments && language === "ruby") { - 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") { @@ -293,17 +285,13 @@ function createRuleContext( const pyProgram = wantComments ? PYTHON_AST_WITH_COMMENTS_PROGRAM : PYTHON_AST_PROGRAM; + const rubyProgram = wantComments + ? RUBY_AST_WITH_COMMENTS_PROGRAM + : RUBY_AST_PROGRAM; const cmd = language === "python" ? [interpreter, "-I", "-c", pyProgram, sourcePath] - : [ - interpreter, - "-rripper", - "-rjson", - "-e", - RUBY_AST_PROGRAM, - sourcePath, - ]; + : [interpreter, "-rripper", "-rjson", "-e", rubyProgram, sourcePath]; const { exitCode, stdout, stderr } = await runAstSubprocess(cmd); if (exitCode !== 0) { const detail = stderr.trim() || `exit code ${exitCode}`; diff --git a/src/formats/rules.ts b/src/formats/rules.ts index b1261d27..58e42727 100644 --- a/src/formats/rules.ts +++ b/src/formats/rules.ts @@ -73,7 +73,10 @@ export type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; * with its delimiters removed (`//`, `/* … *​/`, `#`). `loc` is a position in * the ORIGINAL source — accurate even for `"typescript"`, where the tree's own * `loc` is transpiled-relative (comments are extracted from the pre-transpile - * source). Python comments are always `"line"` (`#`); it has no block comments. + * source). Python comments are always `"line"` (`#`); it has no block + * comments. Ruby `#` comments are `"line"`; each `=begin`/`=end` region is ONE + * `"block"` token whose `value` is the inner content (marker lines stripped) + * and whose `loc` spans the `=begin` line through the `=end` line. */ export interface CommentToken { type: "line" | "block"; @@ -97,8 +100,9 @@ export interface CommentToken { * `fileAtBase()` when you need to detect that first. * - `comments: true` attaches a `comments` array of {@link CommentToken}s to * the returned tree — the structured basis for comment-governance rules, in - * place of line-by-line regex. Supported for `"typescript"`/`"javascript"` - * and `"python"`; requesting it for `"ruby"` throws. + * place of line-by-line regex. Supported for every {@link AstLanguage}; for + * `"ruby"` the array rides on the returned sexp array as a non-index + * property (see {@link RubyAstProgram}). */ export interface AstOptions { rev?: "base"; @@ -165,6 +169,17 @@ export interface PythonAstModule extends PythonAstNode { */ export type RubyAstNode = unknown[]; +/** + * Root Ruby value returned for `"ruby"` — the full `Ripper.sexp` output array + * (`["program", …]`). When parsed with `{ comments: true }`, a `comments` + * array of {@link CommentToken}s rides on the array as a non-index property; + * it is absent otherwise. + */ +export interface RubyAstProgram extends Array { + /** Present only when parsed with `{ comments: true }`. */ + comments?: CommentToken[]; +} + /** * Return type of the language-agnostic `ast()` overload (when `language` is a * non-literal `AstLanguage`). The shape is language-native and deliberately @@ -198,7 +213,7 @@ export interface RuleContext { * * 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 + * {@link PythonAstModule} for `"python"`, and a {@link RubyAstProgram} for * `"ruby"`. The shapes are language-native and are NOT unified (see * ARCH-022) — walk each against its own grammar. * @@ -222,7 +237,11 @@ export interface RuleContext { language: "python", opts?: AstOptions ): Promise; - ast(path: string, language: "ruby", opts?: AstOptions): Promise; + ast( + path: string, + language: "ruby", + opts?: AstOptions + ): Promise; ast(path: string, language: AstLanguage, opts?: AstOptions): Promise; report: RuleReport; } diff --git a/src/helpers/rules-shim.ts b/src/helpers/rules-shim.ts index 130c4448..dececd3f 100644 --- a/src/helpers/rules-shim.ts +++ b/src/helpers/rules-shim.ts @@ -76,7 +76,9 @@ declare type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; * A source comment, attached to the parsed tree's \`comments\` array when * \`ast()\` is called with \`{ comments: true }\`. \`value\` has delimiters * removed; \`loc\` is a position in the ORIGINAL source (accurate even for - * "typescript"). Python comments are always "line". + * "typescript"). Python comments are always "line". Ruby \`#\` comments are + * "line"; each \`=begin\`/\`=end\` region is ONE "block" token (marker lines + * stripped, loc spanning \`=begin\` through \`=end\`). */ declare interface CommentToken { type: "line" | "block"; @@ -93,7 +95,8 @@ declare interface CommentToken { * working tree — use it to detect whether the executable structure changed. * Throws if no base is resolved or the file did not exist at the base. * \`comments: true\` attaches a \`comments\` array of \`CommentToken\`s to the - * returned tree (typescript/javascript/python; ruby throws). + * returned tree (all languages; for ruby it rides on the sexp array as a + * non-index property). */ declare interface AstOptions { rev?: "base"; @@ -160,6 +163,16 @@ declare interface PythonAstModule extends PythonAstNode { */ declare type RubyAstNode = unknown[]; +/** + * Root Ruby value returned for "ruby" — the full \`Ripper.sexp\` output array + * (\`["program", ...]\`). When parsed with \`{ comments: true }\`, a + * \`comments\` array rides on the array as a non-index property. + */ +declare interface RubyAstProgram extends Array { + /** Present only when parsed with \`{ comments: true }\`. */ + comments?: CommentToken[]; +} + /** * Return type of the language-agnostic \`ast()\` overload (when \`language\` * is a non-literal \`AstLanguage\`). Prefer calling \`ast()\` with a string @@ -189,7 +202,7 @@ declare interface RuleContext { * * 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 + * "python", and a \`RubyAstProgram\` 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 @@ -212,7 +225,11 @@ declare interface RuleContext { language: "python", opts?: AstOptions ): Promise; - ast(path: string, language: "ruby", opts?: AstOptions): Promise; + ast( + path: string, + language: "ruby", + opts?: AstOptions + ): Promise; ast(path: string, language: AstLanguage, opts?: AstOptions): Promise; report: RuleReport; } diff --git a/tests/engine/ast-support.test.ts b/tests/engine/ast-support.test.ts index f0ab09b3..50cd38b9 100644 --- a/tests/engine/ast-support.test.ts +++ b/tests/engine/ast-support.test.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import { PYTHON_AST_PROGRAM, RUBY_AST_PROGRAM, + RUBY_AST_WITH_COMMENTS_PROGRAM, interpreterCandidates, parseAstJson, parseErrorMessage, @@ -293,3 +294,118 @@ describe("RUBY_AST_PROGRAM end-to-end", () => { } ); }); + +describe("RUBY_AST_WITH_COMMENTS_PROGRAM end-to-end", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-ast-rb-cmt-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + interface Envelope { + _tree: unknown[]; + comments: Array<{ + type: string; + value: string; + loc: { + start: { line: number; column: number }; + end: { line: number; column: number }; + }; + }>; + } + + test.skipIf(!rubyInterpreter)( + "emits the {_tree, comments} envelope with line and block tokens", + async () => { + const interpreter = rubyInterpreter ?? "ruby"; + const file = join(tempDir, "commented.rb"); + writeFileSync( + file, + [ + "# header", + "x = 1 # trailing", + "=begin", + "block line one", + "block line two", + "=end", + 'y = "# not a comment"', + "", + ].join("\n") + ); + + const { exitCode, stdout } = await runAstSubprocess([ + interpreter, + "-rripper", + "-rjson", + "-e", + RUBY_AST_WITH_COMMENTS_PROGRAM, + file, + ]); + expect(exitCode).toBe(0); + + const envelope = JSON.parse(stdout) as Envelope; + expect(envelope._tree[0]).toBe("program"); + expect(envelope.comments.map((c) => `${c.type}:${c.value}`)).toEqual([ + "line: header", + "line: trailing", + "block:block line one\nblock line two", + ]); + // Line comments: loc from the (line, col) lex tuple, end at token end. + expect(envelope.comments[0].loc).toEqual({ + start: { line: 1, column: 0 }, + end: { line: 1, column: 8 }, + }); + expect(envelope.comments[1].loc.start).toEqual({ line: 2, column: 6 }); + // Block token: ONE token spanning the =begin line through the =end line. + expect(envelope.comments[2].loc.start).toEqual({ line: 3, column: 0 }); + expect(envelope.comments[2].loc.end).toEqual({ line: 6, column: 4 }); + } + ); + + test.skipIf(!rubyInterpreter)( + "comment-free source yields an empty comments list", + async () => { + const interpreter = rubyInterpreter ?? "ruby"; + const file = join(tempDir, "plain.rb"); + writeFileSync(file, "x = 1\n"); + + const { exitCode, stdout } = await runAstSubprocess([ + interpreter, + "-rripper", + "-rjson", + "-e", + RUBY_AST_WITH_COMMENTS_PROGRAM, + file, + ]); + expect(exitCode).toBe(0); + + const envelope = JSON.parse(stdout) as Envelope; + expect(envelope._tree[0]).toBe("program"); + expect(envelope.comments).toEqual([]); + } + ); + + 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_WITH_COMMENTS_PROGRAM, + file, + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("Ruby syntax error"); + } + ); +}); diff --git a/tests/engine/runner-ast-comments.test.ts b/tests/engine/runner-ast-comments.test.ts index 1b63a7d0..cb944f32 100644 --- a/tests/engine/runner-ast-comments.test.ts +++ b/tests/engine/runner-ast-comments.test.ts @@ -16,6 +16,7 @@ import type { CommentToken, EsTreeProgram, PythonAstModule, + RubyAstProgram, RuleSet, } from "../../src/formats/rules"; import { git, safeRmSync } from "../test-utils"; @@ -23,6 +24,7 @@ import { git, safeRmSync } from "../test-utils"; const pythonInterpreter = await probeInterpreter( interpreterCandidates("python") ); +const rubyInterpreter = await probeInterpreter(interpreterCandidates("ruby")); function makeLoadedAdr(ruleSet: RuleSet): LoadResult { return { @@ -193,23 +195,78 @@ describe("ctx.ast({ comments: true })", () => { expect(captured.hasComments).toBe(false); }); - test("ruby with { comments: true } throws a clear unsupported error", async () => { - writeFileSync(join(dir, "src/e.rb"), "# ruby comment\nx = 1\n"); + test.skipIf(!rubyInterpreter)( + "ruby: collects # line comments and =begin/=end block comments with value and loc", + async () => { + writeFileSync( + join(dir, "src/e.rb"), + [ + "# header", + "x = 1 # trailing", + "=begin", + "block line one", + "block line two", + "=end", + 'y = "# not a comment"', + "", + ].join("\n") + ); - const loaded = makeLoadedAdr({ - rules: { - r: { - description: "ruby unsupported", - async check(ctx) { - await ctx.ast("src/e.rb", "ruby", { comments: true }); + const captured: { tree?: RubyAstProgram } = {}; + const loaded = makeLoadedAdr({ + rules: { + r: { + description: "ruby comments", + async check(ctx) { + captured.tree = await ctx.ast("src/e.rb", "ruby", { + comments: true, + }); + }, }, }, - }, - }); + }); - const result = await runChecks(dir, [loaded]); - expect(result.results[0].error).toContain("not supported yet"); - }); + await runChecks(dir, [loaded]); + const comments = captured.tree?.comments ?? []; + expect(comments.map((c) => `${c.type}:${c.value}`)).toEqual([ + "line: header", + "line: trailing", + "block:block line one\nblock line two", + ]); + // Line comments: leading # stripped, newline chomped, loc at the token. + expect(comments[0].loc.start).toEqual({ line: 1, column: 0 }); + expect(comments[0].loc.end).toEqual({ line: 1, column: 8 }); + expect(comments[1].loc.start).toEqual({ line: 2, column: 6 }); + // Block: ONE token, marker lines stripped, spanning =begin through =end. + expect(comments[2].loc.start).toEqual({ line: 3, column: 0 }); + expect(comments[2].loc.end).toEqual({ line: 6, column: 4 }); + // The tree is still the real Ripper sexp (comments ride alongside it). + expect(captured.tree?.[0]).toBe("program"); + } + ); + + test.skipIf(!rubyInterpreter)( + "ruby: no comments property is attached without the flag", + async () => { + writeFileSync(join(dir, "src/f.rb"), "# a comment\nx = 1\n"); + + const captured: { hasComments?: boolean } = {}; + const loaded = makeLoadedAdr({ + rules: { + r: { + description: "ruby opt-in only", + async check(ctx) { + const tree = await ctx.ast("src/f.rb", "ruby"); + captured.hasComments = "comments" in tree; + }, + }, + }, + }); + + await runChecks(dir, [loaded]); + expect(captured.hasComments).toBe(false); + } + ); test.skipIf(!pythonInterpreter)( "python: collects # comments via tokenize with stripped value and position", From 0f8bcf2f1aeed95f1c2f3caf2510912874651d03 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 16 Jul 2026 18:28:48 -0300 Subject: [PATCH 2/4] fix(engine): use character columns and LF-normalized values for Ruby comments Ripper reports byte-offset columns, and the with-comments serializer mixed them with character lengths, so comment locs were wrong on non-ASCII lines under every convention. Convert byte columns to character offsets so Ruby comment locs share the Python/TS unit; the sexp tree's own node positions stay byte-based as Ripper emits them. Also normalize CRLF to LF inside =begin/=end block values: Windows text-mode reads already strip the CR while POSIX preserves it, so the same checked-in CRLF file produced platform-dependent values. Update ARCH-022, the CommentToken JSDoc/shim, and all three doc locales to pin both conventions, with end-to-end tests for each. Signed-off-by: Rhuan Barreto --- .../adrs/ARCH-022-ast-aware-rule-context.md | 9 +-- docs/public/llms-full.txt | 2 +- .../content/docs/nb/reference/rule-api.mdx | 2 +- .../content/docs/pt-br/reference/rule-api.mdx | 2 +- docs/src/content/docs/reference/rule-api.mdx | 2 +- src/engine/ast-support.ts | 23 +++++--- src/formats/rules.ts | 6 +- src/helpers/rules-shim.ts | 4 +- tests/engine/ast-support.test.ts | 59 +++++++++++++++++++ 9 files changed, 91 insertions(+), 18 deletions(-) diff --git a/.archgate/adrs/ARCH-022-ast-aware-rule-context.md b/.archgate/adrs/ARCH-022-ast-aware-rule-context.md index 6ca2562d..82519411 100644 --- a/.archgate/adrs/ARCH-022-ast-aware-rule-context.md +++ b/.archgate/adrs/ARCH-022-ast-aware-rule-context.md @@ -63,12 +63,13 @@ This method dispatches internally based on `language`, and the dispatch mechanis - All four guardrails still run, in order, inside `ast()` for a base parse: path safety (`safePath` on the original path — which also yields the repo-relative form `git show` needs), language plausibility (`AST_LANGUAGE_EXTENSIONS` on the original path), interpreter probe, guarded invocation. - TypeScript/JavaScript base source is parsed in-process by the same `meriyah` path (`parseTsOrJsSource`, factored into `src/engine/js-parser.ts` and shared with the working-tree branch). Python/Ruby base content is not on disk, so it is written to a throwaway OS-temp file — outside the project tree, therefore outside any cwd-derived load path — and handed to the **same, unchanged** `PYTHON_AST_PROGRAM`/`RUBY_AST_PROGRAM` with the **same mandatory `-I` isolation**. The `python-subprocess-isolated` invariant is unchanged. -**Comment access (`{ comments: true }`).** `AstOptions` carries a second field, `comments?: boolean`. With `{ comments: true }`, the returned tree carries a `comments` array of `CommentToken` (`{ type: "line" | "block"; value: string; loc: { start, end } }`) — structured comment data for comment-governance rules (comment length, style, content), in place of the line-by-line regex heuristics that were the only option while the AST exposed no comments at all. `typescript`, `javascript`, and `python` are supported; `ast(path, "ruby", { comments: true })` throws a clear "not supported yet" error (Ripper.lex support is a deferred follow-up). This capability MUST fold into `ast()` — attaching `comments` to the returned tree — rather than a separate method, so it runs inside the same four-guardrail flow and `single-ast-method` stays satisfied (no new method, the catch-all signature unchanged). +**Comment access (`{ comments: true }`).** `AstOptions` carries a second field, `comments?: boolean`. With `{ comments: true }`, the returned tree carries a `comments` array of `CommentToken` (`{ type: "line" | "block"; value: string; loc: { start, end } }`) — structured comment data for comment-governance rules (comment length, style, content), in place of the line-by-line regex heuristics that were the only option while the AST exposed no comments at all. All four languages are supported (Ruby comments come from a second `Ripper.lex` pass — see below). This capability MUST fold into `ast()` — attaching `comments` to the returned tree — rather than a separate method, so it runs inside the same four-guardrail flow and `single-ast-method` stays satisfied (no new method, the catch-all signature unchanged). - `value` has its delimiters removed (`//`, `/* … *​/`, `#`); `type` is `"line"` or `"block"`. Python has only `"line"` comments — `#`; it has no block comments, and `"""` docstrings are string expressions in the `ast` tree, NOT comments. - **TS/JS comments are extracted from the ORIGINAL source, not the tree** (`extractJsComments` in `js-parser.ts`). This is a direct consequence of the transpiled-`loc` note above: `Bun.Transpiler` strips comments before `meriyah` ever sees them, so they cannot come from the ESTree tree. Scanning the pre-transpile source has a genuine advantage — a comment's `loc` is accurate against the original `.ts` file, unlike the tree's own transpiled-relative `loc`. The scanner is string/template-literal aware (a `//` or `/*` inside a string is not a comment) but does NOT track regex literals, so a comment delimiter inside a regex literal (e.g. `/foo\/\//`) is a known blind spot, consistent with the scanner in `source-positions.ts`. - **Python comments come from the `tokenize` module** (the `ast` tree has none), via a second serializer, `PYTHON_AST_WITH_COMMENTS_PROGRAM`, that prints `{ _tree, comments }`; the engine unwraps it to the module tree with `comments` attached. That serializer shares the same `convert()` preamble as the base program and runs under the same mandatory `-I` isolation — the `python-subprocess-isolated` invariant is unchanged. Tokenizer errors on otherwise-parseable source degrade to an empty comment list rather than failing the parse. -- **No new subprocess site and no new guardrail.** Comment support rides entirely inside `ast()`'s existing four-guardrail flow: the only additions are an in-process source scan for TS/JS and an alternate Python serializer program selected inside the same guarded invocation with the same `-I`. Because it folds into `ast()` rather than a new method, `ast-guardrail-ordering` already covers it. +- **Ruby comments come from a second `Ripper.lex` pass** (the `Ripper.sexp` tree has none), via `RUBY_AST_WITH_COMMENTS_PROGRAM`, which prints the same `{ _tree, comments }` envelope; the engine unwraps it with `comments` riding on the sexp array as a non-index property. `#` comments are `"line"` tokens; each `=begin`/`=end` region is ONE `"block"` token whose `value` is the inner content (marker lines stripped) with line endings normalized to LF, so a CRLF file yields the same value on every OS. Ripper reports columns as byte offsets; comment `loc` columns are converted to character offsets to match the Python/TS convention — the sexp tree's own node positions stay byte-based, as Ripper emits them. Lex errors on otherwise-parseable source degrade to an empty comment list, matching the Python fallback. +- **No new subprocess site and no new guardrail.** Comment support rides entirely inside `ast()`'s existing four-guardrail flow: the only additions are an in-process source scan for TS/JS and alternate Python/Ruby serializer programs selected inside the same guarded invocation (Python keeping the same `-I`). Because it folds into `ast()` rather than a new method, `ast-guardrail-ordering` already covers it. **Explicit non-goal: cross-language AST shape unification.** `ctx.ast()` unifies the call site and the failure contract across languages. It does NOT unify the shape of the returned tree. TypeScript/JavaScript returns ESTree-shaped nodes (via `meriyah`); Python returns whatever the standard `ast` module's own node schema produces; Ruby returns `Ripper`'s native s-expression shape. A rule author writing a Python check and a rule author writing a Ruby check are working against two different, language-native grammars, and must know the target language's own AST vocabulary. This ADR accepts that trade explicitly in exchange for avoiding the dependency and distribution cost of a unifying parser (see the tree-sitter alternatives above); it is not a limitation to be silently discovered later. @@ -107,7 +108,7 @@ This method dispatches internally based on `language`, and the dispatch mechanis - **DON'T** read a base revision by spawning git or an interpreter from a `.rules.ts` file — that is exactly the sandbox escape [ARCH-024](./ARCH-024-rule-file-sandbox-boundary.md) blocks; `ast(path, language, { rev: "base" })` and `fileAtBase()` are the sanctioned path. - **DON'T** return `null` from `ast({ rev: "base" })` for the no-base or added-file cases — those MUST throw, distinguishably; only `fileAtBase()` reports them as `null`. - **DON'T** expect a `comments` array without `{ comments: true }` — it is opt-in and absent otherwise. -- **DON'T** request `{ comments: true }` for `"ruby"` yet — it throws; Ruby comment support (Ripper.lex) is a deferred follow-up. +- **DON'T** expect Ruby comment `loc` columns to line up with the sexp tree's own node positions — comment columns are character offsets; Ripper's node positions are byte offsets. - **DON'T** treat the TS/JS comment scan as regex-literal aware — a comment delimiter inside a regex literal is a known blind spot. ## Consequences @@ -156,7 +157,7 @@ This method dispatches internally based on `language`, and the dispatch mechanis The base-revision surface (`ast(path, language, { rev: "base" })` and `fileAtBase()`) is covered by the four rules above unchanged — it adds no new subprocess site and no new guardrail — plus behavioural coverage in `tests/engine/runner-ast-base.test.ts` (base parsing per language, comment-only structural equivalence, and the throw-vs-null semantics of `ast({ rev: "base" })` versus `fileAtBase()`). -The comment surface (`ast(path, language, { comments: true })`) is likewise covered by the four rules unchanged — it folds into `ast()`, adds no new subprocess site, and reuses the same guarded invocation — plus behavioural coverage in `tests/engine/runner-ast-comments.test.ts` (TS line/block extraction with delimiter-stripped values and original-source `loc`, string-literal awareness, JavaScript, the opt-in-only absence of `comments`, the Ruby "not supported yet" throw, and Python `tokenize` extraction including the `#`-inside-a-string exclusion and composition with `{ rev: "base" }`). +The comment surface (`ast(path, language, { comments: true })`) is likewise covered by the four rules unchanged — it folds into `ast()`, adds no new subprocess site, and reuses the same guarded invocation — plus behavioural coverage in `tests/engine/runner-ast-comments.test.ts` (TS line/block extraction with delimiter-stripped values and original-source `loc`, string-literal awareness, JavaScript, the opt-in-only absence of `comments`, Ruby `Ripper.lex` extraction (line/block tokens, character-offset columns, LF-normalized block values), and Python `tokenize` extraction including the `#`-inside-a-string exclusion and composition with `{ rev: "base" }`). ### Manual Enforcement diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index af43ab49..366db8fd 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -5422,7 +5422,7 @@ for (const comment of tree.comments ?? []) { } ``` -Comment positions are accurate against the **original source**, even for TypeScript. This is a deliberate advantage over the tree's own `loc`, which is transpiled-relative for TypeScript (see [AstNode](#astnode)): comments are scanned from the pre-transpile source, so their `loc` never drifts. Python comments are always `type: "line"` (`#`) -- Python has no block comments, and `"""` docstrings are string expressions in the tree, not comments. Ruby `#` comments are `type: "line"`; each `=begin`/`=end` documentation block is a single `type: "block"` token whose `value` is the inner content (the `=begin`/`=end` marker lines stripped, analogous to `/* */` delimiter stripping) and whose `loc` spans the `=begin` line through the `=end` line. For Python and Ruby, comment extraction is a second pass over the same source (`tokenize` / `Ripper.lex`); a tokenizer failure on otherwise-parseable source degrades to an empty comment list rather than failing the parse. The TypeScript/JavaScript scanner is string- and template-literal-aware, but does not track regular-expression literals, so a comment delimiter inside a regex literal is a known blind spot. +Comment positions are accurate against the **original source**, even for TypeScript. This is a deliberate advantage over the tree's own `loc`, which is transpiled-relative for TypeScript (see [AstNode](#astnode)): comments are scanned from the pre-transpile source, so their `loc` never drifts. Python comments are always `type: "line"` (`#`) -- Python has no block comments, and `"""` docstrings are string expressions in the tree, not comments. Ruby `#` comments are `type: "line"`; each `=begin`/`=end` documentation block is a single `type: "block"` token whose `value` is the inner content (the `=begin`/`=end` marker lines stripped, analogous to `/* */` delimiter stripping) and whose `loc` spans the `=begin` line through the `=end` line. Ruby comment `loc` columns are character offsets, consistent with the other languages (Ripper's own sexp node positions are byte offsets), and block `value` line endings are normalized to LF regardless of the source file's line endings. For Python and Ruby, comment extraction is a second pass over the same source (`tokenize` / `Ripper.lex`); a tokenizer failure on otherwise-parseable source degrades to an empty comment list rather than failing the parse. The TypeScript/JavaScript scanner is string- and template-literal-aware, but does not track regular-expression literals, so a comment delimiter inside a regex literal is a known blind spot. ##### Failure behavior diff --git a/docs/src/content/docs/nb/reference/rule-api.mdx b/docs/src/content/docs/nb/reference/rule-api.mdx index cef41d82..2b054803 100644 --- a/docs/src/content/docs/nb/reference/rule-api.mdx +++ b/docs/src/content/docs/nb/reference/rule-api.mdx @@ -268,7 +268,7 @@ for (const comment of tree.comments ?? []) { } ``` -Kommentarposisjoner er nøyaktige mot **originalkilden**, selv for TypeScript. Dette er en bevisst fordel fremfor treets egen `loc`, som er transpilert-relativ for TypeScript (se [AstNode](#astnode)): kommentarer skannes fra kildekoden før transpilering, så deres `loc` driver aldri. Python-kommentarer er alltid `type: "line"` (`#`) -- Python har ingen blokkommentarer, og `"""`-dokstrenger er strengeuttrykk i treet, ikke kommentarer. Ruby `#`-kommentarer er `type: "line"`; hver `=begin`/`=end`-dokumentasjonsblokk er ett enkelt `type: "block"`-token der `value` er det indre innholdet (markørlinjene `=begin`/`=end` er fjernet, tilsvarende fjerningen av `/* */`-skilletegn) og der `loc` spenner fra `=begin`-linjen til og med `=end`-linjen. For Python og Ruby er kommentaruthentingen et eget pass over samme kilde (`tokenize` / `Ripper.lex`); en tokeniseringsfeil på ellers parsbar kode degraderer til en tom kommentarliste i stedet for å feile hele parsingen. TypeScript/JavaScript-skanneren er streng- og malstreng-bevisst, men sporer ikke regulært-uttrykk-literaler, så et kommentarskilletegn inne i en regex-literal er et kjent blindpunkt. +Kommentarposisjoner er nøyaktige mot **originalkilden**, selv for TypeScript. Dette er en bevisst fordel fremfor treets egen `loc`, som er transpilert-relativ for TypeScript (se [AstNode](#astnode)): kommentarer skannes fra kildekoden før transpilering, så deres `loc` driver aldri. Python-kommentarer er alltid `type: "line"` (`#`) -- Python har ingen blokkommentarer, og `"""`-dokstrenger er strengeuttrykk i treet, ikke kommentarer. Ruby `#`-kommentarer er `type: "line"`; hver `=begin`/`=end`-dokumentasjonsblokk er ett enkelt `type: "block"`-token der `value` er det indre innholdet (markørlinjene `=begin`/`=end` er fjernet, tilsvarende fjerningen av `/* */`-skilletegn) og der `loc` spenner fra `=begin`-linjen til og med `=end`-linjen. Kolonnene i `loc` for Ruby-kommentarer er tegnposisjoner, i samsvar med de andre språkene (Rippers egne sexp-nodeposisjoner er byteposisjoner), og linjeskift i `value` for blokker normaliseres til LF uavhengig av kildefilens linjeskift. For Python og Ruby er kommentaruthentingen et eget pass over samme kilde (`tokenize` / `Ripper.lex`); en tokeniseringsfeil på ellers parsbar kode degraderer til en tom kommentarliste i stedet for å feile hele parsingen. TypeScript/JavaScript-skanneren er streng- og malstreng-bevisst, men sporer ikke regulært-uttrykk-literaler, så et kommentarskilletegn inne i en regex-literal er et kjent blindpunkt. ##### Feiloppførsel 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 d4047f53..3175ea63 100644 --- a/docs/src/content/docs/pt-br/reference/rule-api.mdx +++ b/docs/src/content/docs/pt-br/reference/rule-api.mdx @@ -268,7 +268,7 @@ for (const comment of tree.comments ?? []) { } ``` -As posições dos comentários são precisas em relação ao **código-fonte original**, mesmo para TypeScript. Essa é uma vantagem deliberada em relação ao `loc` da própria árvore, que é relativo ao transpilado para TypeScript (veja [AstNode](#astnode)): os comentários são varridos a partir do código-fonte antes da transpilação, então seu `loc` nunca diverge. Comentários em Python são sempre `type: "line"` (`#`) -- Python não tem comentários de bloco, e docstrings `"""` são expressões de string na árvore, não comentários. Comentários `#` em Ruby são `type: "line"`; cada bloco de documentação `=begin`/`=end` é um único token `type: "block"` cujo `value` é o conteúdo interno (as linhas marcadoras `=begin`/`=end` são removidas, de forma análoga à remoção dos delimitadores `/* */`) e cujo `loc` vai da linha do `=begin` até a linha do `=end`. Para Python e Ruby, a extração de comentários é uma segunda passada sobre o mesmo código-fonte (`tokenize` / `Ripper.lex`); uma falha do tokenizador em código que parseia normalmente degrada para uma lista de comentários vazia em vez de falhar o parse. O scanner de TypeScript/JavaScript reconhece literais de string e de template, mas não rastreia literais de expressão regular, então um delimitador de comentário dentro de um literal regex é um ponto cego conhecido. +As posições dos comentários são precisas em relação ao **código-fonte original**, mesmo para TypeScript. Essa é uma vantagem deliberada em relação ao `loc` da própria árvore, que é relativo ao transpilado para TypeScript (veja [AstNode](#astnode)): os comentários são varridos a partir do código-fonte antes da transpilação, então seu `loc` nunca diverge. Comentários em Python são sempre `type: "line"` (`#`) -- Python não tem comentários de bloco, e docstrings `"""` são expressões de string na árvore, não comentários. Comentários `#` em Ruby são `type: "line"`; cada bloco de documentação `=begin`/`=end` é um único token `type: "block"` cujo `value` é o conteúdo interno (as linhas marcadoras `=begin`/`=end` são removidas, de forma análoga à remoção dos delimitadores `/* */`) e cujo `loc` vai da linha do `=begin` até a linha do `=end`. As colunas de `loc` dos comentários Ruby são deslocamentos de caracteres, consistentes com as demais linguagens (as posições de nó do próprio sexp do Ripper são deslocamentos de bytes), e as quebras de linha do `value` de blocos são normalizadas para LF independentemente das quebras de linha do arquivo-fonte. Para Python e Ruby, a extração de comentários é uma segunda passada sobre o mesmo código-fonte (`tokenize` / `Ripper.lex`); uma falha do tokenizador em código que parseia normalmente degrada para uma lista de comentários vazia em vez de falhar o parse. O scanner de TypeScript/JavaScript reconhece literais de string e de template, mas não rastreia literais de expressão regular, então um delimitador de comentário dentro de um literal regex é um ponto cego conhecido. ##### Comportamento de falha diff --git a/docs/src/content/docs/reference/rule-api.mdx b/docs/src/content/docs/reference/rule-api.mdx index eaa127a5..8b929475 100644 --- a/docs/src/content/docs/reference/rule-api.mdx +++ b/docs/src/content/docs/reference/rule-api.mdx @@ -268,7 +268,7 @@ for (const comment of tree.comments ?? []) { } ``` -Comment positions are accurate against the **original source**, even for TypeScript. This is a deliberate advantage over the tree's own `loc`, which is transpiled-relative for TypeScript (see [AstNode](#astnode)): comments are scanned from the pre-transpile source, so their `loc` never drifts. Python comments are always `type: "line"` (`#`) -- Python has no block comments, and `"""` docstrings are string expressions in the tree, not comments. Ruby `#` comments are `type: "line"`; each `=begin`/`=end` documentation block is a single `type: "block"` token whose `value` is the inner content (the `=begin`/`=end` marker lines stripped, analogous to `/* */` delimiter stripping) and whose `loc` spans the `=begin` line through the `=end` line. For Python and Ruby, comment extraction is a second pass over the same source (`tokenize` / `Ripper.lex`); a tokenizer failure on otherwise-parseable source degrades to an empty comment list rather than failing the parse. The TypeScript/JavaScript scanner is string- and template-literal-aware, but does not track regular-expression literals, so a comment delimiter inside a regex literal is a known blind spot. +Comment positions are accurate against the **original source**, even for TypeScript. This is a deliberate advantage over the tree's own `loc`, which is transpiled-relative for TypeScript (see [AstNode](#astnode)): comments are scanned from the pre-transpile source, so their `loc` never drifts. Python comments are always `type: "line"` (`#`) -- Python has no block comments, and `"""` docstrings are string expressions in the tree, not comments. Ruby `#` comments are `type: "line"`; each `=begin`/`=end` documentation block is a single `type: "block"` token whose `value` is the inner content (the `=begin`/`=end` marker lines stripped, analogous to `/* */` delimiter stripping) and whose `loc` spans the `=begin` line through the `=end` line. Ruby comment `loc` columns are character offsets, consistent with the other languages (Ripper's own sexp node positions are byte offsets), and block `value` line endings are normalized to LF regardless of the source file's line endings. For Python and Ruby, comment extraction is a second pass over the same source (`tokenize` / `Ripper.lex`); a tokenizer failure on otherwise-parseable source degrades to an empty comment list rather than failing the parse. The TypeScript/JavaScript scanner is string- and template-literal-aware, but does not track regular-expression literals, so a comment delimiter inside a regex literal is a known blind spot. ##### Failure behavior diff --git a/src/engine/ast-support.ts b/src/engine/ast-support.ts index e74aeaa9..56076628 100644 --- a/src/engine/ast-support.ts +++ b/src/engine/ast-support.ts @@ -152,8 +152,14 @@ puts JSON.generate(sexp, max_nesting: false) * each `=begin`/`=end` region becomes ONE `type: "block"` token whose value is * the inner content (marker lines stripped, like TS/JS stripping the * delimiters) and whose loc spans the `=begin` line through the `=end` line. - * Lex errors on otherwise-parseable source degrade to an empty comment list - * rather than failing the parse, matching Python's tokenizer-error fallback. + * Ripper reports columns as BYTE offsets; they are converted to CHARACTER + * columns (via a byteslice of the source line) so comment locs share the + * Python/TS unit — the sexp tree's own node positions stay byte-based, as + * Ripper emits them. Block values normalize CRLF to LF: Windows text-mode + * reads already strip the CR, so normalizing keeps the value identical + * across OSes. Lex errors on otherwise-parseable source degrade to an empty + * comment list rather than failing the parse, matching Python's + * tokenizer-error fallback. */ export const RUBY_AST_WITH_COMMENTS_PROGRAM = ` source = File.read(ARGV[0], mode: "r:bom|utf-8") @@ -164,30 +170,33 @@ if sexp.nil? end comments = [] begin + lines = source.lines + char_col = ->(line, byte_col) { (lines[line - 1] || "").byteslice(0, byte_col).to_s.length } embdoc = nil Ripper.lex(source).each do |(line, col), event, tok, _state| case event when :on_comment + start_col = char_col.call(line, col) comments << { type: "line", value: tok.sub(/\\A#/, "").chomp, loc: { - start: { line: line, column: col }, - end: { line: line, column: col + tok.chomp.length }, + start: { line: line, column: start_col }, + end: { line: line, column: start_col + tok.chomp.length }, }, } when :on_embdoc_beg - embdoc = { line: line, col: col, value: +"" } + embdoc = { line: line, col: char_col.call(line, col), value: +"" } when :on_embdoc embdoc[:value] << tok unless embdoc.nil? when :on_embdoc_end unless embdoc.nil? comments << { type: "block", - value: embdoc[:value].chomp, + value: embdoc[:value].gsub(/\\r\\n/, "\\n").chomp, loc: { start: { line: embdoc[:line], column: embdoc[:col] }, - end: { line: line, column: col + tok.chomp.length }, + end: { line: line, column: char_col.call(line, col) + tok.chomp.length }, }, } embdoc = nil diff --git a/src/formats/rules.ts b/src/formats/rules.ts index 58e42727..3a5e7759 100644 --- a/src/formats/rules.ts +++ b/src/formats/rules.ts @@ -75,8 +75,10 @@ export type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; * `loc` is transpiled-relative (comments are extracted from the pre-transpile * source). Python comments are always `"line"` (`#`); it has no block * comments. Ruby `#` comments are `"line"`; each `=begin`/`=end` region is ONE - * `"block"` token whose `value` is the inner content (marker lines stripped) - * and whose `loc` spans the `=begin` line through the `=end` line. + * `"block"` token whose `value` is the inner content (marker lines stripped, + * line endings normalized to LF) and whose `loc` spans the `=begin` line + * through the `=end` line. Ruby comment columns are character offsets like the + * other languages — the sexp tree's own node positions are byte offsets. */ export interface CommentToken { type: "line" | "block"; diff --git a/src/helpers/rules-shim.ts b/src/helpers/rules-shim.ts index dececd3f..c7fc52f1 100644 --- a/src/helpers/rules-shim.ts +++ b/src/helpers/rules-shim.ts @@ -78,7 +78,9 @@ declare type AstLanguage = "typescript" | "javascript" | "python" | "ruby"; * removed; \`loc\` is a position in the ORIGINAL source (accurate even for * "typescript"). Python comments are always "line". Ruby \`#\` comments are * "line"; each \`=begin\`/\`=end\` region is ONE "block" token (marker lines - * stripped, loc spanning \`=begin\` through \`=end\`). + * stripped, line endings normalized to LF, loc spanning \`=begin\` through + * \`=end\`). Ruby comment columns are character offsets — the sexp tree's own + * node positions are byte offsets. */ declare interface CommentToken { type: "line" | "block"; diff --git a/tests/engine/ast-support.test.ts b/tests/engine/ast-support.test.ts index 50cd38b9..174f2d00 100644 --- a/tests/engine/ast-support.test.ts +++ b/tests/engine/ast-support.test.ts @@ -389,6 +389,65 @@ describe("RUBY_AST_WITH_COMMENTS_PROGRAM end-to-end", () => { } ); + test.skipIf(!rubyInterpreter)( + "reports character columns, not Ripper's byte offsets, on non-ASCII lines", + async () => { + const interpreter = rubyInterpreter ?? "ruby"; + const file = join(tempDir, "nonascii.rb"); + // Multi-byte é before and inside the comment: byte and char cols diverge. + writeFileSync(file, "é = 1 # café\n"); + + const { exitCode, stdout } = await runAstSubprocess([ + interpreter, + "-rripper", + "-rjson", + "-e", + RUBY_AST_WITH_COMMENTS_PROGRAM, + file, + ]); + expect(exitCode).toBe(0); + + const envelope = JSON.parse(stdout) as Envelope; + expect(envelope.comments).toHaveLength(1); + expect(envelope.comments[0].value).toBe(" café"); + // Char cols 6..12, matching Python tokenize on the same layout (bytes: 7..14). + expect(envelope.comments[0].loc).toEqual({ + start: { line: 1, column: 6 }, + end: { line: 1, column: 12 }, + }); + } + ); + + test.skipIf(!rubyInterpreter)( + "normalizes CRLF in block values so content is OS-independent", + async () => { + const interpreter = rubyInterpreter ?? "ruby"; + const file = join(tempDir, "crlf.rb"); + writeFileSync( + file, + "x = 1\r\n=begin\r\nblock one\r\nblock two\r\n=end\r\n" + ); + + const { exitCode, stdout } = await runAstSubprocess([ + interpreter, + "-rripper", + "-rjson", + "-e", + RUBY_AST_WITH_COMMENTS_PROGRAM, + file, + ]); + expect(exitCode).toBe(0); + + const envelope = JSON.parse(stdout) as Envelope; + // LF-joined on every OS — POSIX reads keep the \r bytes, Windows strips them. + expect(envelope.comments.map((c) => `${c.type}:${c.value}`)).toEqual([ + "block:block one\nblock two", + ]); + expect(envelope.comments[0].loc.start).toEqual({ line: 2, column: 0 }); + expect(envelope.comments[0].loc.end).toEqual({ line: 5, column: 4 }); + } + ); + test.skipIf(!rubyInterpreter)( "exits 1 with 'Ruby syntax error' for invalid source", async () => { From fc9a21756ce7961d9a703ee3d641c1c4c7208d33 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 16 Jul 2026 23:34:07 -0300 Subject: [PATCH 3/4] docs(adr): correct test attribution in ARCH-022 compliance section Signed-off-by: Rhuan Barreto --- .archgate/adrs/ARCH-022-ast-aware-rule-context.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.archgate/adrs/ARCH-022-ast-aware-rule-context.md b/.archgate/adrs/ARCH-022-ast-aware-rule-context.md index 82519411..744410da 100644 --- a/.archgate/adrs/ARCH-022-ast-aware-rule-context.md +++ b/.archgate/adrs/ARCH-022-ast-aware-rule-context.md @@ -157,7 +157,7 @@ This method dispatches internally based on `language`, and the dispatch mechanis The base-revision surface (`ast(path, language, { rev: "base" })` and `fileAtBase()`) is covered by the four rules above unchanged — it adds no new subprocess site and no new guardrail — plus behavioural coverage in `tests/engine/runner-ast-base.test.ts` (base parsing per language, comment-only structural equivalence, and the throw-vs-null semantics of `ast({ rev: "base" })` versus `fileAtBase()`). -The comment surface (`ast(path, language, { comments: true })`) is likewise covered by the four rules unchanged — it folds into `ast()`, adds no new subprocess site, and reuses the same guarded invocation — plus behavioural coverage in `tests/engine/runner-ast-comments.test.ts` (TS line/block extraction with delimiter-stripped values and original-source `loc`, string-literal awareness, JavaScript, the opt-in-only absence of `comments`, Ruby `Ripper.lex` extraction (line/block tokens, character-offset columns, LF-normalized block values), and Python `tokenize` extraction including the `#`-inside-a-string exclusion and composition with `{ rev: "base" }`). +The comment surface (`ast(path, language, { comments: true })`) is likewise covered by the four rules unchanged — it folds into `ast()`, adds no new subprocess site, and reuses the same guarded invocation — plus behavioural coverage in `tests/engine/runner-ast-comments.test.ts` (TS line/block extraction with delimiter-stripped values and original-source `loc`, string-literal awareness, JavaScript, the opt-in-only absence of `comments`, Ruby `Ripper.lex` extraction (line/block tokens), and Python `tokenize` extraction including the `#`-inside-a-string exclusion and composition with `{ rev: "base" }`). The Ruby character-offset columns (versus Ripper's byte offsets) and LF-normalized block values are pinned at the serializer level in `tests/engine/ast-support.test.ts`. ### Manual Enforcement From d86a6c302c62ea2596d8249f348150c6220892e1 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Thu, 16 Jul 2026 23:51:49 -0300 Subject: [PATCH 4/4] refactor(test): address review feedback on ruby comment support - widen the AstNode fallback union to RubyAstProgram in rules.ts and rules-shim.ts so non-literal-language callers get typed access to the comments property (RubyAstNode stays exported for nested nodes) - use Bun.write instead of writeFileSync in the new async test callbacks, per the Bun built-ins guideline - add a Ripper.lex-failure test covering the rescue-StandardError degrade path: parse still exits 0 with a valid tree and an empty comments list Signed-off-by: Rhuan Barreto --- src/formats/rules.ts | 2 +- src/helpers/rules-shim.ts | 2 +- tests/engine/ast-support.test.ts | 41 +++++++++++++++++++++--- tests/engine/runner-ast-comments.test.ts | 4 +-- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/formats/rules.ts b/src/formats/rules.ts index 3a5e7759..f2ac918f 100644 --- a/src/formats/rules.ts +++ b/src/formats/rules.ts @@ -188,7 +188,7 @@ export interface RubyAstProgram extends Array { * 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; +export type AstNode = EsTreeProgram | PythonAstModule | RubyAstProgram; // --- Rule Context --- diff --git a/src/helpers/rules-shim.ts b/src/helpers/rules-shim.ts index c7fc52f1..010657c1 100644 --- a/src/helpers/rules-shim.ts +++ b/src/helpers/rules-shim.ts @@ -180,7 +180,7 @@ declare interface RubyAstProgram extends Array { * 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 type AstNode = EsTreeProgram | PythonAstModule | RubyAstProgram; declare interface RuleContext { projectRoot: string; diff --git a/tests/engine/ast-support.test.ts b/tests/engine/ast-support.test.ts index 174f2d00..981ae8fc 100644 --- a/tests/engine/ast-support.test.ts +++ b/tests/engine/ast-support.test.ts @@ -323,7 +323,7 @@ describe("RUBY_AST_WITH_COMMENTS_PROGRAM end-to-end", () => { async () => { const interpreter = rubyInterpreter ?? "ruby"; const file = join(tempDir, "commented.rb"); - writeFileSync( + await Bun.write( file, [ "# header", @@ -371,7 +371,7 @@ describe("RUBY_AST_WITH_COMMENTS_PROGRAM end-to-end", () => { async () => { const interpreter = rubyInterpreter ?? "ruby"; const file = join(tempDir, "plain.rb"); - writeFileSync(file, "x = 1\n"); + await Bun.write(file, "x = 1\n"); const { exitCode, stdout } = await runAstSubprocess([ interpreter, @@ -389,13 +389,44 @@ describe("RUBY_AST_WITH_COMMENTS_PROGRAM end-to-end", () => { } ); + test.skipIf(!rubyInterpreter)( + "degrades to an empty comments list when Ripper.lex raises", + async () => { + const interpreter = rubyInterpreter ?? "ruby"; + const file = join(tempDir, "lexfail.rb"); + await Bun.write(file, "x = 1 # comment\n"); + + // Monkey-patch prelude: Ripper.sexp still succeeds while Ripper.lex + // raises, exercising the rescue-StandardError degrade path without + // modifying the shipped program. + const lexFailingProgram = [ + 'Ripper.singleton_class.prepend(Module.new { def lex(*) raise "boom" end })', + RUBY_AST_WITH_COMMENTS_PROGRAM, + ].join("\n"); + + const { exitCode, stdout } = await runAstSubprocess([ + interpreter, + "-rripper", + "-rjson", + "-e", + lexFailingProgram, + file, + ]); + expect(exitCode).toBe(0); + + const envelope = JSON.parse(stdout) as Envelope; + expect(envelope._tree[0]).toBe("program"); + expect(envelope.comments).toEqual([]); + } + ); + test.skipIf(!rubyInterpreter)( "reports character columns, not Ripper's byte offsets, on non-ASCII lines", async () => { const interpreter = rubyInterpreter ?? "ruby"; const file = join(tempDir, "nonascii.rb"); // Multi-byte é before and inside the comment: byte and char cols diverge. - writeFileSync(file, "é = 1 # café\n"); + await Bun.write(file, "é = 1 # café\n"); const { exitCode, stdout } = await runAstSubprocess([ interpreter, @@ -423,7 +454,7 @@ describe("RUBY_AST_WITH_COMMENTS_PROGRAM end-to-end", () => { async () => { const interpreter = rubyInterpreter ?? "ruby"; const file = join(tempDir, "crlf.rb"); - writeFileSync( + await Bun.write( file, "x = 1\r\n=begin\r\nblock one\r\nblock two\r\n=end\r\n" ); @@ -453,7 +484,7 @@ describe("RUBY_AST_WITH_COMMENTS_PROGRAM end-to-end", () => { async () => { const interpreter = rubyInterpreter ?? "ruby"; const file = join(tempDir, "invalid.rb"); - writeFileSync(file, "def broken(\n"); + await Bun.write(file, "def broken(\n"); const { exitCode, stderr } = await runAstSubprocess([ interpreter, diff --git a/tests/engine/runner-ast-comments.test.ts b/tests/engine/runner-ast-comments.test.ts index cb944f32..d42dd0a1 100644 --- a/tests/engine/runner-ast-comments.test.ts +++ b/tests/engine/runner-ast-comments.test.ts @@ -198,7 +198,7 @@ describe("ctx.ast({ comments: true })", () => { test.skipIf(!rubyInterpreter)( "ruby: collects # line comments and =begin/=end block comments with value and loc", async () => { - writeFileSync( + await Bun.write( join(dir, "src/e.rb"), [ "# header", @@ -248,7 +248,7 @@ describe("ctx.ast({ comments: true })", () => { test.skipIf(!rubyInterpreter)( "ruby: no comments property is attached without the flag", async () => { - writeFileSync(join(dir, "src/f.rb"), "# a comment\nx = 1\n"); + await Bun.write(join(dir, "src/f.rb"), "# a comment\nx = 1\n"); const captured: { hasComments?: boolean } = {}; const loaded = makeLoadedAdr({