From b17d647c6917c0bb28ccdd0da5fbbb7466aa4351 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 22:08:27 +0100 Subject: [PATCH 1/9] fix: harden Ruby mapper edge cases --- CHANGELOG.md | 2 +- docs/feature-mapping.md | 6 +- src/detect.ts | 91 +++++++++-- src/mapper.test.ts | 129 +++++++++++++++- src/mappers/node.ts | 56 +++++-- src/mappers/ruby.ts | 332 +++++++++++++++++++++++++++++++--------- src/ruby.ts | 145 ++++++++++++++++++ 7 files changed, 656 insertions(+), 105 deletions(-) create mode 100644 src/ruby.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 657917c..e1bb10b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Added security ownership, CodeQL, Dependabot, dependency review, and a private disclosure policy for repository automation and package integrity, plus fixed the first CodeQL mapper sanitizer finding. - Added JVM semantic role mapping from Java annotations, imports, inheritance, interfaces, and method signatures. -- Added Ruby and Rails feature mapping while excluding legacy Rails secrets from reviewable config. +- Added Ruby and Rails feature mapping while excluding legacy Rails secrets from reviewable config, thanks @inertia186. - Fixed Ruby/Rails project detection so `gems.rb` uses Bundler commands and Rails JavaScript roots avoid duplicate Node feature queues. - Improved Python mapping for `setup.cfg`/`setup.py` project metadata and console scripts, plus `black --check .` format defaults. - Added selected package script mapping for Node workspace packages. diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index 706c01b..6e376a2 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -91,9 +91,9 @@ tuple, or set literals. FastAPI paths can be positional strings or literal pyright, and black. Ruby mapping covers project metadata, executables, source groups, RSpec and -Minitest suites, and Rails app structure. Rails legacy `config/secrets.yml` is -not mapped as reviewable config because it can contain provider-sensitive -secrets. +Minitest suites, and Rails app structure. Rails legacy `config/secrets.yml`, +`config/database.yml`, and `config/initializers/secret_token.rb` are not mapped +as reviewable config because they can contain provider-sensitive secrets. Known gaps: diff --git a/src/detect.ts b/src/detect.ts index c4864e8..c5d8be6 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -3,6 +3,12 @@ import { join } from "node:path"; import { pathExists } from "./fs.js"; import { projectNameFromRoot, discoverGit } from "./git.js"; import { stableId } from "./id.js"; +import { + fileHasRubyShebang, + rubyDependencyNames, + rubyGemspecPaths, + stripRubyComments, +} from "./ruby.js"; import { ProjectRecord, ProjectCommands } from "./types.js"; type PackageJson = { @@ -350,11 +356,15 @@ function pythonRunCommand(runner: string | null, command: string): string { async function rubyDefaultCommands(root: string): Promise { const source = await rubyDependencySource(root); + const dependencies = rubyDependencyNames(source); const hasBundle = await hasBundlerConfig(root); - const hasRspec = /\brspec\b/iu.test(source) || (await containsRubySpecFile(root, 5)); - const hasMinitest = /\bminitest\b/iu.test(source) || (await containsRubyTestFile(root, 5)); + const hasRspec = + dependencies.has("rspec") || + dependencies.has("rspec-rails") || + (await containsRubySpecFile(root, 5)); + const hasMinitest = dependencies.has("minitest") || (await containsRubyTestFile(root, 5)); const hasRubocop = - /\brubocop\b/iu.test(source) || + dependencies.has("rubocop") || (await pathExists(join(root, ".rubocop.yml"))) || (await pathExists(join(root, ".rubocop_todo.yml"))); const run = hasBundle ? "bundle exec " : ""; @@ -377,12 +387,10 @@ async function rubyDependencySource(root: string): Promise { chunks.push(await readFile(join(root, path), "utf8")); } } - for (const entry of await readdir(root).catch(() => [])) { - if (entry.endsWith(".gemspec")) { - chunks.push(await readFile(join(root, entry), "utf8")); - } + for (const path of await rubyGemspecPaths(root)) { + chunks.push(await readFile(join(root, path), "utf8")); } - return chunks.join("\n"); + return stripRubyComments(chunks.join("\n")); } async function pythonProjectInfo(root: string): Promise { @@ -882,7 +890,7 @@ async function isRubyProject(root: string): Promise { (await pathExists(join(root, "gems.rb"))) || (await pathExists(join(root, "Rakefile"))) || (await pathExists(join(root, "config.ru"))) || - (await containsFileWithExtension(root, ".gemspec", 1)) + (await rubyGemspecPaths(root)).length > 0 ) { return true; } @@ -977,20 +985,79 @@ async function collectPythonFrameworkScanFiles( } async function containsReviewableRubyFile(root: string): Promise { - for (const prefix of ["app", "lib", "scripts", "exe", "bin"]) { - if (await containsFileWithExtension(join(root, prefix), ".rb", 4)) { + if (await containsFileMatching(root, 0, isReviewableRubyFileName)) { + return true; + } + for (const prefix of ["app", "lib"]) { + if (await containsFileMatching(join(root, prefix), 4, isReviewableRubyFileName)) { + return true; + } + } + for (const prefix of ["scripts", "script", "exe", "bin"]) { + if (await containsRubyExecutableSource(join(root, prefix), 4)) { + return true; + } + } + return false; +} + +function isReviewableRubyFileName(entry: string): boolean { + return ( + entry.endsWith(".rb") && + !entry.startsWith("test_") && + !entry.endsWith("_spec.rb") && + !entry.endsWith("_test.rb") && + !/(?:generated|\.gen)\.rb$/iu.test(entry) + ); +} + +async function containsRubyExecutableSource(dir: string, remainingDepth: number): Promise { + if (remainingDepth < 0 || !(await pathExists(dir))) { + return false; + } + const dirInfo = await lstat(dir); + if (!dirInfo.isDirectory() || dirInfo.isSymbolicLink()) { + return false; + } + for (const entry of await readdir(dir)) { + if (shouldSkipSearchEntry(entry)) { + continue; + } + const full = join(dir, entry); + const info = await lstat(full); + if (info.isSymbolicLink()) { + continue; + } + if ( + info.isFile() && + (isReviewableRubyFileName(entry) || + (isRubyShebangCandidate(entry) && (await fileHasRubyShebang(full)))) + ) { + return true; + } + if (info.isDirectory() && (await containsRubyExecutableSource(full, remainingDepth - 1))) { return true; } } return false; } +function isRubyShebangCandidate(path: string): boolean { + return !path.includes("."); +} + async function containsRubySpecFile(root: string, maxDepth: number): Promise { return containsFileMatching(root, maxDepth, (entry) => entry.endsWith("_spec.rb")); } async function containsRubyTestFile(root: string, maxDepth: number): Promise { - return containsFileMatching(root, maxDepth, (entry) => entry.endsWith("_test.rb")); + return containsFileMatching( + root, + maxDepth, + (entry) => + entry.endsWith("_test.rb") || + (/^test_.+\.rb$/u.test(entry) && !/^test_helpers?\.rb$/u.test(entry)), + ); } async function containsFileNamed(root: string, name: string, maxDepth: number): Promise { diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 7872b55..5930a6e 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1,4 +1,4 @@ -import { symlink } from "node:fs/promises"; +import { mkdir, symlink } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { detectProject } from "./detect.js"; @@ -403,6 +403,75 @@ describe("mapFeatures", () => { }); }); + it("does not treat Ruby test helpers as Minitest tests", async () => { + const root = await fixtureRoot("clawpatch-map-ruby-test-helper-"); + await writeFixture(root, "Gemfile", "source 'https://rubygems.org'\n"); + await writeFixture(root, "lib/test_helper.rb", "module TestHelper\nend\n"); + await writeFixture(root, "test/test_helper.rb", "require 'minitest/autorun'\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const owned = result.features.flatMap((feature) => feature.ownedFiles.map((ref) => ref.path)); + + expect(project.detected.commands.test).toBeNull(); + expect(result.features.map((feature) => feature.title)).not.toContain("Ruby test suite test"); + expect(owned).toContain("lib/test_helper.rb"); + expect(owned).not.toContain("test/test_helper.rb"); + }); + + it("ignores generated nested gemspec artifacts", async () => { + const root = await fixtureRoot("clawpatch-map-ruby-generated-gemspec-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "node-only" })); + await writeFixture( + root, + "dist/generated.gemspec", + "Gem::Specification.new do |spec|\n spec.name = 'built-artifact'\n spec.add_dependency 'rails'\nend\n", + ); + await writeFixture( + root, + "tmp/runtime.gemspec", + "Gem::Specification.new do |spec|\n spec.name = 'tmp-artifact'\n spec.add_dependency 'rails'\nend\n", + ); + await writeFixture( + root, + "log/runtime.gemspec", + "Gem::Specification.new do |spec|\n spec.name = 'log-artifact'\n spec.add_dependency 'rails'\nend\n", + ); + await writeFixture(root, "config/application.rb", "module NotRails\nend\n"); + await writeFixture(root, "app/assets/admin.ts", "export const admin = true;\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + const nodeAsset = result.features.find((feature) => + feature.ownedFiles.some((file) => file.path === "app/assets/admin.ts"), + ); + + expect(project.detected.languages).not.toContain("ruby"); + expect(project.detected.frameworks).not.toContain("rails"); + expect(titles).not.toContain("Ruby project built-artifact"); + expect(titles).not.toContain("Ruby project tmp-artifact"); + expect(titles).not.toContain("Ruby project log-artifact"); + expect(nodeAsset?.title).toBe("Node source app"); + }); + + it("ignores gemspec directories during Ruby dependency scans", async () => { + const root = await fixtureRoot("clawpatch-map-ruby-gemspec-dir-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "gemspec-dir" })); + await mkdir(join(root, "fake.gemspec")); + await writeFixture(root, "config/application.rb", "module NotRails\nend\n"); + await writeFixture(root, "app/assets/admin.ts", "export const admin = true;\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const nodeAsset = result.features.find((feature) => + feature.ownedFiles.some((file) => file.path === "app/assets/admin.ts"), + ); + + expect(project.detected.frameworks).not.toContain("rails"); + expect(nodeAsset?.title).toBe("Node source app"); + }); + it("maps Gemfile-only Jekyll sites without mistaking dependencies for project names", async () => { const root = await fixtureRoot("clawpatch-map-jekyll-"); await writeFixture( @@ -454,6 +523,7 @@ describe("mapFeatures", () => { await writeFixture(root, "Gemfile", "source 'https://rubygems.org'\ngem 'rails'\ngem 'pg'\n"); await writeFixture(root, "config/application.rb", "module FixtureRails\nend\n"); await writeFixture(root, "config/routes.rb", "Rails.application.routes.draw do\nend\n"); + await writeFixture(root, "config/database.yml", "production:\n password: secret\n"); await writeFixture(root, "config/secrets.yml", "redacted: placeholder\n"); await writeFixture( root, @@ -465,12 +535,32 @@ describe("mapFeatures", () => { "config/initializers/filter.rb", "Rails.application.config.filter_parameters += [:password]\n", ); + for (let index = 0; index < 14; index += 1) { + await writeFixture( + root, + `config/initializers/initializer_${String(index).padStart(2, "0")}.rb`, + "Rails.application.configure {}\n", + ); + } + await writeFixture( + root, + "config/initializers/secret_token.rb", + "Rails.application.config.secret_token = 'secret'\n", + ); await writeFixture(root, "db/schema.rb", "ActiveRecord::Schema.define do\nend\n"); + await writeFixture(root, "db/structure.sql", "CREATE TABLE widgets (id bigint);\n"); await writeFixture( root, "db/migrate/20200101000000_create_widgets.rb", "class CreateWidgets < ActiveRecord::Migration[6.1]\nend\n", ); + for (let index = 1; index < 14; index += 1) { + await writeFixture( + root, + `db/migrate/202001010000${String(index).padStart(2, "0")}_create_widgets_${index}.rb`, + "class CreateWidgets < ActiveRecord::Migration[6.1]\nend\n", + ); + } await writeFixture( root, "bin/rails", @@ -485,12 +575,24 @@ describe("mapFeatures", () => { await writeFixture(root, "app/views/widgets/index.html.haml", "%h1 Widgets\n"); await writeFixture(root, "app/views/widgets/index.json.jbuilder", "json.widgets []\n"); await writeFixture(root, "app/assets/javascripts/widgets.coffee", "console.log 'widgets'\n"); + await writeFixture(root, "app/assets/javascripts/admin.tsx", "export function Admin() {}\n"); + await writeFixture(root, "app/assets/builds/application.js", "console.log('built');\n"); await writeFixture(root, "app/assets/stylesheets/widgets.scss", ".widgets { color: black; }\n"); await writeFixture( root, "app/javascript/controllers/widgets_controller.js", "export function connect() {}\n", ); + await writeFixture( + root, + "app/javascript/stylesheets/application.scss", + ".widgets { display: grid; }\n", + ); + await writeFixture( + root, + "app/components/widget_component.ts", + "export function wireWidgetComponent() {}\n", + ); await writeFixture(root, "src/client.ts", "export function client() {}\n"); await writeFixture(root, "lib/client.ts", "export function libClient() {}\n"); await writeFixture(root, "pages/home.tsx", "export function Home() { return null; }\n"); @@ -513,24 +615,45 @@ describe("mapFeatures", () => { const railsConfig = result.features.find( (feature) => feature.title === "Rails application configuration", ); + const railsDatabaseFeatures = result.features.filter( + (feature) => feature.source === "rails-database", + ); + const railsAssetRefs = result.features + .filter((feature) => feature.source === "rails-assets") + .flatMap((feature) => feature.ownedFiles.map((ref) => ref.path)); expect(project.detected.frameworks).toContain("rails"); expect(titles).not.toContain("Ruby CLI command rails"); - expect(titles).not.toContain("Node source app"); expect(titles).not.toContain("Node source app/assets"); + expect(titles).toContain("Node source app"); expect(titles).toContain("Node source app/javascript"); expect(titles).toContain("Node source src"); expect(titles).toContain("Node source lib"); expect(titles).toContain("Node source pages"); expect(titles).toContain("Rails application configuration"); expect(titles).toContain("Rails database schema and migrations"); + expect(titles).toContain("Rails database schema and migrations db/migrate#2"); expect(titles).toContain("Rails views app/views"); expect(titles).toContain("Rails assets app/assets"); + expect(railsDatabaseFeatures.every((feature) => feature.ownedFiles.length <= 12)).toBe(true); + expect(referencedFiles).toContain("db/structure.sql"); + expect(referencedFiles).toContain("app/components/widget_component.ts"); + expect(railsAssetRefs).toContain("app/assets/javascripts/admin.tsx"); + expect(railsAssetRefs).toContain("app/javascript/stylesheets/application.scss"); + expect(railsAssetRefs).not.toContain("app/javascript/controllers/widgets_controller.js"); + expect(railsAssetRefs).not.toContain("app/assets/builds/application.js"); expect(rubyProject?.trustBoundaries).toEqual( expect.arrayContaining(["database", "network", "serialization"]), ); expect(railsConfig?.ownedFiles.map((ref) => ref.path)).toContain("config/routes.rb"); + expect(railsConfig?.ownedFiles.slice(0, 12).map((ref) => ref.path)).toContain( + "config/routes.rb", + ); expect(railsConfig?.ownedFiles.map((ref) => ref.path)).not.toContain("config/secrets.yml"); + expect(railsConfig?.ownedFiles.map((ref) => ref.path)).not.toContain("config/database.yml"); + expect(railsConfig?.ownedFiles.map((ref) => ref.path)).not.toContain( + "config/initializers/secret_token.rb", + ); expect( result.features.filter((feature) => feature.ownedFiles.some( @@ -538,7 +661,9 @@ describe("mapFeatures", () => { ), ), ).toHaveLength(1); + expect(referencedFiles).not.toContain("config/database.yml"); expect(referencedFiles).not.toContain("config/secrets.yml"); + expect(referencedFiles).not.toContain("config/initializers/secret_token.rb"); }); it("maps workspace packages and splits large Node source groups", async () => { diff --git a/src/mappers/node.ts b/src/mappers/node.ts index 85e6f73..8289d00 100644 --- a/src/mappers/node.ts +++ b/src/mappers/node.ts @@ -1,6 +1,8 @@ +import { readFile } from "node:fs/promises"; import { basename, dirname, extname, join } from "node:path"; import { packageBins, packageScripts } from "../detect.js"; import { pathExists } from "../fs.js"; +import { rubyDependencyNames, rubyGemspecPaths, stripRubyComments } from "../ruby.js"; import { normalize, packageKind, @@ -132,14 +134,17 @@ async function sourceGroupSeeds(root: string, info: PackageInfo): Promise isReviewableNodeSourceFile(path) && !isRailsExcludedNodeSourcePath(info, path), + (path) => + isReviewableNodeSourceFile(path) && + !isRailsExcludedNodeSourcePath(info, railsPackage, sourceRoot, path), ); if (files.length === 0) { continue; @@ -179,12 +184,11 @@ async function sourceGroupSeeds(root: string, info: PackageInfo): Promise { - if (await isRailsPackage(root, info.root)) { - const railsSourceDirectories = sourceDirectories.filter((dir) => dir !== "app"); +function packageSourceRoots(info: PackageInfo, railsPackage: boolean): string[] { + if (railsPackage) { return [ ...new Set( - [...railsSourceDirectories, "app/javascript", "app/packs", "app/frontend"].map((dir) => + [...sourceDirectories, "app/javascript", "app/packs", "app/frontend"].map((dir) => packageRelativePath(info.root, dir), ), ), @@ -193,13 +197,30 @@ async function packageSourceRoots(root: string, info: PackageInfo): Promise packageRelativePath(info.root, dir)); } -function isRailsExcludedNodeSourcePath(info: PackageInfo, path: string): boolean { - return pathMatchesPrefix(path, packageRelativePath(info.root, "app/assets")); +function isRailsExcludedNodeSourcePath( + info: PackageInfo, + railsPackage: boolean, + sourceRoot: string, + path: string, +): boolean { + if (!railsPackage) { + return false; + } + if (pathMatchesPrefix(path, packageRelativePath(info.root, "app/assets"))) { + return true; + } + if (sourceRoot !== packageRelativePath(info.root, "app")) { + return false; + } + return ["app/javascript", "app/packs", "app/frontend"].some((dir) => + pathMatchesPrefix(path, packageRelativePath(info.root, dir)), + ); } async function packageTestFiles(root: string, info: PackageInfo): Promise { + const railsPackage = await isRailsPackage(root, info.root); const prefixes = [ - ...(await packageSourceRoots(root, info)), + ...packageSourceRoots(info, railsPackage), ...testDirectories.map((dir) => packageRelativePath(info.root, dir)), ]; return (await walk(root, prefixes)).filter(isNodeTestPath).slice(0, 200); @@ -208,11 +229,24 @@ async function packageTestFiles(root: string, info: PackageInfo): Promise { return ( packageRoot === "." && - (await pathExists(join(root, "Gemfile"))) && - (await pathExists(join(root, "config/application.rb"))) + (await pathExists(join(root, "config/application.rb"))) && + (await hasRailsDependency(root)) ); } +async function hasRailsDependency(root: string): Promise { + const chunks: string[] = []; + for (const path of ["Gemfile", "gems.rb"]) { + if (await pathExists(join(root, path))) { + chunks.push(await readFile(join(root, path), "utf8")); + } + } + for (const path of await rubyGemspecPaths(root)) { + chunks.push(await readFile(join(root, path), "utf8")); + } + return rubyDependencyNames(stripRubyComments(chunks.join("\n"))).has("rails"); +} + function partitionSourceFiles( sourceRoot: string, files: string[], diff --git a/src/mappers/ruby.ts b/src/mappers/ruby.ts index 6e848e4..9a25d3a 100644 --- a/src/mappers/ruby.ts +++ b/src/mappers/ruby.ts @@ -1,6 +1,12 @@ import { readFile, readdir } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { pathExists } from "../fs.js"; +import { + fileHasRubyShebang, + rubyDependencyNames, + rubyGemspecPaths, + stripRubyComments, +} from "../ruby.js"; import { isSafeDirectory, isSafeFile, @@ -27,11 +33,19 @@ type RubyProjectInfo = { const metadataFiles = ["Gemfile", "gems.rb", "Rakefile", "config.ru"] as const; const sourceRoots = ["app", "lib", "scripts"] as const; -const executableRoots = ["exe", "bin", "script"] as const; +const executableRoots = ["exe", "bin", "script", "scripts"] as const; const railsBinstubs = new Set(["bundle", "rails", "rake", "setup", "spring", "yarn"]); const sourceGroupMaxOwnedFiles = 12; const sourceGroupMaxTests = 8; const jekyllContentMaxOwnedFiles = 24; +const rootToolingFiles = new Set([ + "Gemfile.lock", + "README.md", + "package.json", + "package-lock.json", + "pnpm-lock.yaml", + "tsconfig.json", +]); export async function rubySeeds(root: string): Promise { if (!(await isRubyProject(root))) { @@ -40,7 +54,10 @@ export async function rubySeeds(root: string): Promise { const info = await rubyProjectInfo(root); const projectFiles = await rubyMetadataFiles(root); const testFiles = await rubyTestFiles(root); - const testCommand = await rubyTestCommand(root, info, testFiles); + const runPrefix = await rubyRunPrefix(root); + const testCommand = rubyProjectTestCommand(runPrefix, info, testFiles); + const commandForTest = (path: string): string | null => + rubyTestCommandForPath(path, runPrefix, info); const railsApp = await isRailsApp(root, info); const seeds: FeatureSeed[] = []; @@ -64,7 +81,7 @@ export async function rubySeeds(root: string): Promise { } for (const executable of await rubyExecutables(root, railsApp)) { - const tests = associatedTests([executable], testFiles, testCommand); + const tests = associatedTests([executable], testFiles, commandForTest); seeds.push({ title: `Ruby CLI command ${basename(executable)}`, summary: `Ruby executable ${executable}.`, @@ -106,7 +123,7 @@ export async function rubySeeds(root: string): Promise { } for (const group of await rubySourceGroups(root)) { - const tests = associatedTests(group.files, testFiles, testCommand); + const tests = associatedTests(group.files, testFiles, commandForTest); seeds.push({ title: `Ruby source ${group.label}`, summary: @@ -133,7 +150,7 @@ export async function rubySeeds(root: string): Promise { seeds.push(...(await jekyllSeeds(root, info))); seeds.push(...(await railsSeeds(root, info))); - for (const testSuite of standaloneTestSuites(testFiles, testCommand)) { + for (const testSuite of standaloneTestSuites(testFiles, commandForTest)) { seeds.push(testSuite); } @@ -147,12 +164,13 @@ async function isRubyProject(root: string): Promise { (await pathExists(join(root, "Rakefile"))) || (await pathExists(join(root, "config.ru"))) || (await rubyGemspecs(root)).length > 0 || + (await rootRubySourceFiles(root)).length > 0 || (await containsReviewableRubySource(root)) ); } async function rubyProjectInfo(root: string): Promise { - const source = await rubyDependencySource(root); + const source = stripRubyComments(await rubyDependencySource(root)); return { name: rubyProjectName(source), dependencies: rubyDependencyNames(source), @@ -173,11 +191,7 @@ async function rubyMetadataFiles(root: string): Promise { } async function rubyGemspecs(root: string): Promise { - const entries = await readdir(root, { withFileTypes: true }).catch(() => []); - return entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".gemspec")) - .map((entry) => entry.name) - .toSorted(); + return rubyGemspecPaths(root); } async function rubyDependencySource(root: string): Promise { @@ -191,27 +205,36 @@ async function rubyDependencySource(root: string): Promise { } function rubyProjectName(source: string): string | null { - return /^\s*(?:spec|s)\.name\s*=\s*["']([^"']+)["']/mu.exec(source)?.[1] ?? null; -} - -function rubyDependencyNames(source: string): Set { - const names = new Set(); - for (const line of source.split("\n")) { - const match = - /^\s*(?:gem|s\.add_dependency|s\.add_development_dependency|spec\.add_dependency|spec\.add_development_dependency)\s*\(?\s*["']([^"']+)["']/u.exec( - line, - ); - if (match?.[1] !== undefined) { - names.add(match[1].toLowerCase()); - } + const assignment = /^\s*[A-Za-z_][A-Za-z0-9_]*\.name\s*=\s*(.+)$/mu.exec(source)?.[1]; + return assignment === undefined ? null : rubyStringLiteral(assignment); +} + +function rubyStringLiteral(source: string): string | null { + const trimmed = source.trimStart(); + const quoted = /^(['"])(.*?)\1/u.exec(trimmed)?.[2]; + if (quoted !== undefined) { + return quoted; } - return names; + const percent = /^%[qQ]([<{[(]|[^A-Za-z0-9\s])/.exec(trimmed)?.[1]; + if (percent === undefined) { + return null; + } + const close = + new Map([ + ["<", ">"], + ["{", "}"], + ["[", "]"], + ["(", ")"], + ]).get(percent) ?? percent; + const rest = trimmed.slice(3); + const end = rest.indexOf(close); + return end === -1 ? null : rest.slice(0, end); } function rubyTrustBoundaries(name: string, dependencies: Set): TrustBoundary[] { const boundaries = new Set(packageTrustBoundaries(name)); const text = `${name} ${[...dependencies].join(" ")}`; - if (/\b(redis|sequel|pg|mysql|sqlite|activerecord)\b/iu.test(text)) { + if (/\b(redis|sequel|pg|mysql2?|sqlite3?|activerecord)\b/iu.test(text)) { boundaries.add("database"); boundaries.add("network"); boundaries.add("serialization"); @@ -348,18 +371,24 @@ async function railsSeeds(root: string, info: RubyProjectInfo): Promise 0) { + for (const group of partitionSourceFiles("db", dbFiles, sourceGroupMaxOwnedFiles)) { seeds.push({ - title: "Rails database schema and migrations", - summary: `Rails database files with ${dbFiles.length} migration/schema file(s).`, + title: + group.label === "db" + ? "Rails database schema and migrations" + : `Rails database schema and migrations ${group.label}`, + summary: `Rails database group ${group.label} with ${group.files.length} migration/schema file(s).`, kind: "service", source: "rails-database", confidence: "high", - entryPath: dbFiles[0] ?? "db", - symbol: null, + entryPath: group.label, + symbol: group.label, route: null, command: null, - ownedFiles: dbFiles.map((path) => ({ path, reason: "rails database file" })), + ownedFiles: group.files.map((path) => ({ + path, + reason: `rails database group ${group.label}`, + })), contextFiles: [], tags: ["ruby", "rails", "database"], trustBoundaries: uniqueTrustBoundaries([...trustBoundaries, "database"]), @@ -419,7 +448,6 @@ async function railsConfigFiles(root: string): Promise { "config/application.rb", "config/routes.rb", "config/environment.rb", - "config/database.yml", "config/boot.rb", ]); for (const prefix of ["config/environments", "config/initializers", "config/locales"]) { @@ -428,11 +456,16 @@ async function railsConfigFiles(root: string): Promise { } files.push( ...(await walk(root, [prefix])).filter( - (path) => /\.(rb|ya?ml)$/u.test(path) && !rubyShouldSkip(path), + (path) => + /\.(rb|ya?ml)$/u.test(path) && !rubyShouldSkip(path) && !isSensitiveRailsConfig(path), ), ); } - return uniquePaths(files); + return uniquePathsInOrder(files); +} + +function isSensitiveRailsConfig(path: string): boolean { + return /^config\/initializers\/(?:secret_token|secret_key_base)\.rb$/u.test(path); } async function railsDatabaseFiles(root: string): Promise { @@ -440,7 +473,7 @@ async function railsDatabaseFiles(root: string): Promise { return []; } return (await walk(root, ["db"])) - .filter((path) => /\.(rb|ya?ml)$/u.test(path) && !rubyShouldSkip(path)) + .filter((path) => /\.(rb|ya?ml|sql)$/u.test(path) && !rubyShouldSkip(path)) .toSorted(); } @@ -455,16 +488,30 @@ async function railsViewGroups(root: string): Promise { } async function railsAssetGroups(root: string): Promise { - if (!(await isSafeDirectory(root, join(root, "app/assets")))) { - return []; + const hasNodePackage = await pathExists(join(root, "package.json")); + const roots = ["app/assets", "app/javascript", "app/packs", "app/frontend"]; + const groups: SourceGroup[] = []; + for (const prefix of roots) { + if (!(await isSafeDirectory(root, join(root, prefix)))) { + continue; + } + const files = (await walk(root, [prefix])).filter( + (path) => + isRailsAssetFile(path, hasNodePackage) && + !rubyShouldSkip(path) && + !pathMatchesPrefix(path, "app/assets/builds") && + !path.includes("/images/"), + ); + groups.push(...partitionSourceFiles(prefix, files, jekyllContentMaxOwnedFiles)); } - const files = (await walk(root, ["app/assets"])).filter( - (path) => - /\.(js|coffee|css|scss|sass)$/u.test(path) && - !rubyShouldSkip(path) && - !path.includes("/images/"), - ); - return partitionSourceFiles("app/assets", files, jekyllContentMaxOwnedFiles); + return groups; +} + +function isRailsAssetFile(path: string, hasNodePackage: boolean): boolean { + if (hasNodePackage && !pathMatchesPrefix(path, "app/assets")) { + return /\.(coffee|css|scss|sass)$/u.test(path); + } + return /\.(ts|tsx|js|jsx|mts|cts|mjs|cjs|coffee|css|scss|sass)$/u.test(path); } async function existingFiles(root: string, candidates: string[]): Promise { @@ -483,7 +530,7 @@ async function jekyllRootPages(root: string): Promise { .filter((entry) => entry.isFile()) .map((entry) => entry.name) .filter((path) => /\.(md|html|json)$/u.test(path)) - .filter((path) => !["README.md", "Gemfile.lock"].includes(path)) + .filter((path) => !rootToolingFiles.has(path)) .toSorted(); } @@ -556,15 +603,23 @@ function groupByPostYear(posts: string[]): Map { async function rubyExecutables(root: string, skipRailsBinstubs: boolean): Promise { const executables: string[] = []; - for (const executableRoot of executableRoots) { + for (const executableRoot of await rubyExecutableRoots(root)) { if (!(await isSafeDirectory(root, join(root, executableRoot)))) { continue; } for (const path of await walk(root, [executableRoot])) { - if (skipRailsBinstubs && executableRoot === "bin" && railsBinstubs.has(basename(path))) { + if ( + skipRailsBinstubs && + executableRoot.endsWith("bin") && + railsBinstubs.has(basename(path)) + ) { continue; } - if (!rubyShouldSkip(path) && (path.endsWith(".rb") || (await hasRubyShebang(root, path)))) { + if ( + !rubyShouldSkip(path) && + (path.endsWith(".rb") || + (isRubyShebangCandidate(path) && (await hasRubyShebang(root, path)))) + ) { executables.push(path); } } @@ -576,13 +631,13 @@ async function hasRubyShebang(root: string, path: string): Promise { if (!(await isSafeFile(root, join(root, path)))) { return false; } - const head = (await readFile(join(root, path), "utf8").catch(() => "")).slice(0, 160); - return /^#!.*\bruby\b/u.test(head); + return fileHasRubyShebang(join(root, path)); } async function rubySourceGroups(root: string): Promise { const groups: SourceGroup[] = []; - for (const sourceRoot of sourceRoots) { + groups.push(...(await rootRubySourceGroups(root))); + for (const sourceRoot of await rubySourceRoots(root)) { if (!(await isSafeDirectory(root, join(root, sourceRoot)))) { continue; } @@ -592,56 +647,143 @@ async function rubySourceGroups(root: string): Promise { return groups; } +async function rootRubySourceGroups(root: string): Promise { + return chunkFiles("root", await rootRubySourceFiles(root), sourceGroupMaxOwnedFiles); +} + +async function rootRubySourceFiles(root: string): Promise { + return (await readdir(root, { withFileTypes: true }).catch(() => [])) + .filter((entry) => entry.isFile() && isReviewableRubySourceFile(entry.name)) + .map((entry) => entry.name) + .toSorted(); +} + async function rubyTestFiles(root: string): Promise { - const files = (await walk(root, ["spec", "test", ...sourceRoots])) + const rootTests = await rootRubyTestFiles(root); + const files = (await walk(root, await rubyTestRoots(root))) .filter(isRubyTestPath) .filter((path) => !rubyShouldSkip(path) && !isRubyFixturePath(path)); - return uniquePaths(files).slice(0, 200); + return uniquePaths([...rootTests, ...files]).slice(0, 200); } -async function rubyTestCommand( - root: string, +async function rubySourceRoots(root: string): Promise { + const roots: string[] = [...sourceRoots]; + for (const packageRoot of await nestedRubyPackageRoots(root)) { + roots.push(...sourceRoots.map((sourceRoot) => `${packageRoot}/${sourceRoot}`)); + } + return uniquePaths(roots); +} + +async function rubyExecutableRoots(root: string): Promise { + const roots: string[] = [...executableRoots]; + for (const packageRoot of await nestedRubyPackageRoots(root)) { + roots.push(...executableRoots.map((executableRoot) => `${packageRoot}/${executableRoot}`)); + } + return uniquePaths(roots); +} + +async function rubyTestRoots(root: string): Promise { + const roots = ["spec", "test", ...(await rubySourceRoots(root))]; + for (const packageRoot of await nestedRubyPackageRoots(root)) { + roots.push(`${packageRoot}/spec`, `${packageRoot}/test`); + } + return uniquePaths(roots); +} + +async function nestedRubyPackageRoots(root: string): Promise { + const packageRoots = new Set(); + for (const gemspec of await rubyGemspecs(root)) { + const packageRoot = dirname(gemspec); + if ( + packageRoot !== "." && + !rubyShouldSkip(packageRoot) && + (await isSafeDirectory(root, join(root, packageRoot))) + ) { + packageRoots.add(packageRoot); + } + } + return [...packageRoots].toSorted(); +} + +async function rootRubyTestFiles(root: string): Promise { + return (await readdir(root, { withFileTypes: true }).catch(() => [])) + .filter((entry) => entry.isFile() && isRubyTestPath(entry.name)) + .map((entry) => entry.name) + .toSorted(); +} + +async function rubyRunPrefix(root: string): Promise { + return (await pathExists(join(root, "Gemfile"))) || (await pathExists(join(root, "gems.rb"))) + ? "bundle exec " + : ""; +} + +function rubyProjectTestCommand( + runPrefix: string, info: RubyProjectInfo, testFiles: string[], -): Promise { - const run = (await pathExists(join(root, "Gemfile"))) ? "bundle exec " : ""; +): string | null { if (info.hasRspec || testFiles.some((path) => path.endsWith("_spec.rb"))) { - return `${run}rspec`; + return `${runPrefix}rspec`; } if (info.hasMinitest || testFiles.some((path) => path.endsWith("_test.rb"))) { - return `${run}rake test`; + return `${runPrefix}rake test`; } return null; } -function standaloneTestSuites(testFiles: string[], command: string | null): FeatureSeed[] { +function rubyTestCommandForPath( + path: string, + runPrefix: string, + info: RubyProjectInfo, +): string | null { + if (path.endsWith("_spec.rb") || (path.startsWith("spec/") && info.hasRspec)) { + return `${runPrefix}rspec`; + } + if (isRubyMinitestPath(path) || (path.startsWith("test/") && info.hasMinitest)) { + return `${runPrefix}rake test`; + } + return rubyProjectTestCommand(runPrefix, info, [path]); +} + +function standaloneTestSuites( + testFiles: string[], + commandForTest: (path: string) => string | null, +): FeatureSeed[] { const groups = new Map(); for (const path of testFiles) { const root = path.startsWith("spec/") ? "spec" : path.startsWith("test/") ? "test" - : dirname(path); + : path.includes("/") + ? dirname(path) + : "root"; groups.set(root, [...(groups.get(root) ?? []), path]); } return [...groups.entries()] .toSorted(([left], [right]) => left.localeCompare(right)) - .map(([label, files]) => ({ - title: `Ruby test suite ${label}`, - summary: `Ruby test files in ${label}.`, + .flatMap(([label, files]) => + label === "root" + ? chunkFiles(label, files.toSorted(), sourceGroupMaxOwnedFiles) + : partitionSourceFiles(label, files, sourceGroupMaxOwnedFiles), + ) + .map((group) => ({ + title: `Ruby test suite ${group.label}`, + summary: `Ruby test files in ${group.label}.`, kind: "test-suite", source: "ruby-test-suite", confidence: "medium", - entryPath: label, - symbol: label, + entryPath: group.label, + symbol: group.label, route: null, command: null, - ownedFiles: files.map((path) => ({ path, reason: "ruby test file" })), + ownedFiles: group.files.map((path) => ({ path, reason: "ruby test file" })), contextFiles: [], - tests: files.map((path) => ({ path, command })), + tests: group.files.map((path) => ({ path, command: commandForTest(path) })), tags: ["ruby", "test"], trustBoundaries: [], - testCommand: command, + testCommand: group.files.length > 0 ? commandForTest(group.files[0] ?? "") : null, skipNearbyTests: true, })); } @@ -750,7 +892,11 @@ function bucketPrefix(files: string[], sourceRoot: string, depth: number, segmen return [...parts, segment].join("/"); } -function associatedTests(files: string[], tests: string[], command: string | null): SeedTestRef[] { +function associatedTests( + files: string[], + tests: string[], + commandForTest: (path: string) => string | null, +): SeedTestRef[] { const fileStems = new Set(files.map((file) => basename(file).replace(/\.rb$/u, ""))); const dirs = new Set(files.map((file) => dirname(file))); return tests @@ -758,11 +904,12 @@ function associatedTests(files: string[], tests: string[], command: string | nul const testStem = basename(test) .replace(/_spec\.rb$/u, "") .replace(/_test\.rb$/u, "") - .replace(/\.rb$/u, ""); + .replace(/\.rb$/u, "") + .replace(/^test_/u, ""); return [...dirs].some((dir) => pathMatchesPrefix(test, dir)) || fileStems.has(testStem); }) .slice(0, sourceGroupMaxTests) - .map((path) => ({ path, command })); + .map((path) => ({ path, command: commandForTest(path) })); } function isReviewableRubySourceFile(path: string): boolean { @@ -776,8 +923,16 @@ function isReviewableRubySourceFile(path: string): boolean { } function isRubyTestPath(path: string): boolean { + return path.endsWith(".rb") && (basename(path).endsWith("_spec.rb") || isRubyMinitestPath(path)); +} + +function isRubyMinitestPath(path: string): boolean { const name = basename(path); - return path.endsWith(".rb") && (name.endsWith("_spec.rb") || name.endsWith("_test.rb")); + return name.endsWith("_test.rb") || (/^test_.+\.rb$/u.test(name) && !isRubyTestHelper(name)); +} + +function isRubyTestHelper(name: string): boolean { + return /^test_helpers?\.rb$/u.test(name); } function isRubyFixturePath(path: string): boolean { @@ -785,11 +940,15 @@ function isRubyFixturePath(path: string): boolean { } function rubyShouldSkip(path: string): boolean { - return shouldSkip(path) || /(^|\/)(\.bundle|vendor\/bundle|tmp|log)(\/|$)/u.test(path); + return ( + shouldSkip(path) || + /(^|\/)(\.bundle|vendor\/bundle)(\/|$)/u.test(path) || + /^(?:tmp|log)(?:\/|$)/u.test(path) + ); } async function containsReviewableRubySource(root: string): Promise { - for (const sourceRoot of [...sourceRoots, ...executableRoots]) { + for (const sourceRoot of await rubySourceRoots(root)) { if (!(await isSafeDirectory(root, join(root, sourceRoot)))) { continue; } @@ -797,9 +956,30 @@ async function containsReviewableRubySource(root: string): Promise { return true; } } + for (const sourceRoot of await rubyExecutableRoots(root)) { + if (!(await isSafeDirectory(root, join(root, sourceRoot)))) { + continue; + } + for (const path of await walk(root, [sourceRoot])) { + if ( + isReviewableRubySourceFile(path) || + (isRubyShebangCandidate(path) && (await hasRubyShebang(root, path))) + ) { + return true; + } + } + } return false; } +function isRubyShebangCandidate(path: string): boolean { + return !basename(path).includes("."); +} + function uniquePaths(paths: string[]): string[] { return [...new Set(paths)].toSorted(); } + +function uniquePathsInOrder(paths: string[]): string[] { + return [...new Set(paths)]; +} diff --git a/src/ruby.ts b/src/ruby.ts new file mode 100644 index 0000000..aefc08f --- /dev/null +++ b/src/ruby.ts @@ -0,0 +1,145 @@ +import { open, readdir } from "node:fs/promises"; +import { join } from "node:path"; + +const gemspecSearchSkipEntries = new Set([ + ".bundle", + ".git", + ".clawpatch", + ".worktrees", + "build", + "dist", + "log", + "node_modules", + "tmp", + "vendor", + "fixtures", + "__fixtures__", + "testdata", +]); + +export function stripRubyComments(source: string): string { + return stripRubyBlockComments(source).split("\n").map(stripRubyLineComment).join("\n"); +} + +export async function fileHasRubyShebang(path: string): Promise { + const handle = await open(path, "r").catch(() => null); + if (handle === null) { + return false; + } + try { + const buffer = Buffer.alloc(160); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + return /^#!.*\bruby\b/u.test(buffer.subarray(0, bytesRead).toString("utf8")); + } finally { + await handle.close(); + } +} + +export async function rubyGemspecPaths(root: string): Promise { + const paths: string[] = []; + const entries = await readdir(root, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(".gemspec")) { + paths.push(entry.name); + continue; + } + if ( + !entry.isDirectory() || + entry.isSymbolicLink() || + gemspecSearchSkipEntries.has(entry.name) + ) { + continue; + } + const nestedEntries = await readdir(join(root, entry.name), { withFileTypes: true }).catch( + () => [], + ); + for (const nestedEntry of nestedEntries) { + if (nestedEntry.isFile() && nestedEntry.name.endsWith(".gemspec")) { + paths.push(`${entry.name}/${nestedEntry.name}`); + } + } + } + return paths.toSorted(); +} + +export function rubyDependencyNames(source: string): Set { + const names = new Set(); + for (const line of source.split("\n")) { + const args = + /^\s*(?:[A-Za-z_][A-Za-z0-9_]*\.add_(?:runtime_)?dependency|[A-Za-z_][A-Za-z0-9_]*\.add_development_dependency|gem)\s*\(?\s*(.+)$/u.exec( + line, + )?.[1] ?? null; + const name = args === null ? null : rubyStringLiteral(args); + if (name !== null) { + names.add(name.toLowerCase()); + } + } + return names; +} + +function rubyStringLiteral(source: string): string | null { + const trimmed = source.trimStart(); + const quoted = /^(['"])(.*?)\1/u.exec(trimmed)?.[2]; + if (quoted !== undefined) { + return quoted; + } + const percent = /^%[qQ]([<{[(]|[^A-Za-z0-9\s])/.exec(trimmed)?.[1]; + if (percent === undefined) { + return null; + } + const close = + new Map([ + ["<", ">"], + ["{", "}"], + ["[", "]"], + ["(", ")"], + ]).get(percent) ?? percent; + const rest = trimmed.slice(3); + const end = rest.indexOf(close); + return end === -1 ? null : rest.slice(0, end); +} + +function stripRubyLineComment(line: string): string { + let quote: "'" | '"' | null = null; + let escaped = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + quote = null; + } + continue; + } + if (char === "'" || char === '"') { + quote = char; + } else if (char === "#") { + return line.slice(0, index); + } + } + return line; +} + +function stripRubyBlockComments(source: string): string { + const lines: string[] = []; + let inBlockComment = false; + for (const line of source.split("\n")) { + if (/^\s*=begin\b/u.test(line)) { + inBlockComment = true; + lines.push(""); + continue; + } + if (inBlockComment) { + if (/^\s*=end\b/u.test(line)) { + inBlockComment = false; + } + lines.push(""); + continue; + } + lines.push(line); + } + return lines.join("\n"); +} From b93e59ddc812f8139a61b678f4ef8369fe76b067 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 22:19:07 +0100 Subject: [PATCH 2/9] fix: detect RuboCop extension lint gems --- src/detect.ts | 8 +++++++- src/mapper.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/detect.ts b/src/detect.ts index c5d8be6..a05d3cc 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -364,7 +364,7 @@ async function rubyDefaultCommands(root: string): Promise { (await containsRubySpecFile(root, 5)); const hasMinitest = dependencies.has("minitest") || (await containsRubyTestFile(root, 5)); const hasRubocop = - dependencies.has("rubocop") || + hasRubocopDependency(dependencies) || (await pathExists(join(root, ".rubocop.yml"))) || (await pathExists(join(root, ".rubocop_todo.yml"))); const run = hasBundle ? "bundle exec " : ""; @@ -376,6 +376,12 @@ async function rubyDefaultCommands(root: string): Promise { }; } +function hasRubocopDependency(dependencies: Set): boolean { + return [...dependencies].some( + (dependency) => dependency === "rubocop" || dependency.startsWith("rubocop-"), + ); +} + async function hasBundlerConfig(root: string): Promise { return (await pathExists(join(root, "Gemfile"))) || (await pathExists(join(root, "gems.rb"))); } diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 5930a6e..09f3a01 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -403,6 +403,16 @@ describe("mapFeatures", () => { }); }); + it("detects RuboCop extension gems as Ruby lint providers", async () => { + const root = await fixtureRoot("clawpatch-map-rubocop-extension-"); + await writeFixture(root, "Gemfile", "source 'https://rubygems.org'\ngem 'rubocop-rails'\n"); + await writeFixture(root, "lib/fixture.rb", "module Fixture\nend\n"); + + const project = await detectProject(root); + + expect(project.detected.commands.lint).toBe("bundle exec rubocop"); + }); + it("does not treat Ruby test helpers as Minitest tests", async () => { const root = await fixtureRoot("clawpatch-map-ruby-test-helper-"); await writeFixture(root, "Gemfile", "source 'https://rubygems.org'\n"); From 6b076124e67c8e1d27ace3396c3e3376966a6904 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 22:26:56 +0100 Subject: [PATCH 3/9] fix: skip generated gemspec search roots --- src/mapper.test.ts | 12 ++++++++++++ src/ruby.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 09f3a01..545cc43 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -447,6 +447,16 @@ describe("mapFeatures", () => { "log/runtime.gemspec", "Gem::Specification.new do |spec|\n spec.name = 'log-artifact'\n spec.add_dependency 'rails'\nend\n", ); + await writeFixture( + root, + "target/generated.gemspec", + "Gem::Specification.new do |spec|\n spec.name = 'target-artifact'\n spec.add_dependency 'rails'\nend\n", + ); + await writeFixture( + root, + ".build/generated.gemspec", + "Gem::Specification.new do |spec|\n spec.name = 'build-artifact'\n spec.add_dependency 'rails'\nend\n", + ); await writeFixture(root, "config/application.rb", "module NotRails\nend\n"); await writeFixture(root, "app/assets/admin.ts", "export const admin = true;\n"); @@ -462,6 +472,8 @@ describe("mapFeatures", () => { expect(titles).not.toContain("Ruby project built-artifact"); expect(titles).not.toContain("Ruby project tmp-artifact"); expect(titles).not.toContain("Ruby project log-artifact"); + expect(titles).not.toContain("Ruby project target-artifact"); + expect(titles).not.toContain("Ruby project build-artifact"); expect(nodeAsset?.title).toBe("Node source app"); }); diff --git a/src/ruby.ts b/src/ruby.ts index aefc08f..dca0d87 100644 --- a/src/ruby.ts +++ b/src/ruby.ts @@ -3,18 +3,32 @@ import { join } from "node:path"; const gemspecSearchSkipEntries = new Set([ ".bundle", + ".build", ".git", ".clawpatch", ".worktrees", + ".swiftpm", "build", + "coverage", "dist", "log", "node_modules", "tmp", + "target", "vendor", + ".venv", + "venv", + "__pycache__", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", "fixtures", "__fixtures__", "testdata", + "Pods", + "Carthage", + "SourcePackages", + "DerivedData", ]); export function stripRubyComments(source: string): string { From 90d517a0e9c7b408b77740998ce126a2ceee2354 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 22:35:07 +0100 Subject: [PATCH 4/9] fix: scope nested Ruby gemspec dependencies --- src/detect.ts | 2 +- src/mapper.test.ts | 25 +++++++++++++++++++++++++ src/mappers/ruby.ts | 4 ++-- src/ruby.ts | 6 +++++- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index a05d3cc..5f7dc3f 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -896,7 +896,7 @@ async function isRubyProject(root: string): Promise { (await pathExists(join(root, "gems.rb"))) || (await pathExists(join(root, "Rakefile"))) || (await pathExists(join(root, "config.ru"))) || - (await rubyGemspecPaths(root)).length > 0 + (await rubyGemspecPaths(root, { includeNested: true })).length > 0 ) { return true; } diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 545cc43..241c11b 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -494,6 +494,31 @@ describe("mapFeatures", () => { expect(nodeAsset?.title).toBe("Node source app"); }); + it("does not apply nested Ruby gemspec dependencies to root Rails detection", async () => { + const root = await fixtureRoot("clawpatch-map-nested-ruby-gemspec-"); + await writeFixture(root, "package.json", JSON.stringify({ name: "mixed-root" })); + await writeFixture( + root, + "engine/engine.gemspec", + "Gem::Specification.new do |spec|\n spec.name = 'engine'\n spec.add_dependency 'rails'\nend\n", + ); + await writeFixture(root, "engine/lib/engine.rb", "module Engine\nend\n"); + await writeFixture(root, "config/application.rb", "module NotRails\nend\n"); + await writeFixture(root, "app/assets/admin.ts", "export const admin = true;\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + const nodeAsset = result.features.find((feature) => + feature.ownedFiles.some((file) => file.path === "app/assets/admin.ts"), + ); + + expect(project.detected.languages).toContain("ruby"); + expect(project.detected.frameworks).not.toContain("rails"); + expect(titles).not.toContain("Rails application configuration"); + expect(nodeAsset?.title).toBe("Node source app"); + }); + it("maps Gemfile-only Jekyll sites without mistaking dependencies for project names", async () => { const root = await fixtureRoot("clawpatch-map-jekyll-"); await writeFixture( diff --git a/src/mappers/ruby.ts b/src/mappers/ruby.ts index 9a25d3a..0b8aafb 100644 --- a/src/mappers/ruby.ts +++ b/src/mappers/ruby.ts @@ -191,12 +191,12 @@ async function rubyMetadataFiles(root: string): Promise { } async function rubyGemspecs(root: string): Promise { - return rubyGemspecPaths(root); + return rubyGemspecPaths(root, { includeNested: true }); } async function rubyDependencySource(root: string): Promise { const chunks: string[] = []; - for (const path of [...metadataFiles, ...(await rubyGemspecs(root))]) { + for (const path of [...metadataFiles, ...(await rubyGemspecPaths(root))]) { if (await pathExists(join(root, path))) { chunks.push(await readFile(join(root, path), "utf8")); } diff --git a/src/ruby.ts b/src/ruby.ts index dca0d87..8823fac 100644 --- a/src/ruby.ts +++ b/src/ruby.ts @@ -49,7 +49,10 @@ export async function fileHasRubyShebang(path: string): Promise { } } -export async function rubyGemspecPaths(root: string): Promise { +export async function rubyGemspecPaths( + root: string, + options: { includeNested?: boolean } = {}, +): Promise { const paths: string[] = []; const entries = await readdir(root, { withFileTypes: true }).catch(() => []); for (const entry of entries) { @@ -58,6 +61,7 @@ export async function rubyGemspecPaths(root: string): Promise { continue; } if ( + options.includeNested !== true || !entry.isDirectory() || entry.isSymbolicLink() || gemspecSearchSkipEntries.has(entry.name) From 76734570ef07b55d2484ca2ad35f9e9b46d159ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 22:42:31 +0100 Subject: [PATCH 5/9] fix: scope Ruby test prefix detection --- src/detect.ts | 16 ++++++++++------ src/mapper.test.ts | 2 ++ src/mappers/ruby.ts | 7 ++++++- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index 5f7dc3f..bda8aec 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -1057,12 +1057,16 @@ async function containsRubySpecFile(root: string, maxDepth: number): Promise { - return containsFileMatching( - root, - maxDepth, - (entry) => - entry.endsWith("_test.rb") || - (/^test_.+\.rb$/u.test(entry) && !/^test_helpers?\.rb$/u.test(entry)), + return ( + (await containsFileMatching(root, 0, isRubyMinitestFileName)) || + (await containsFileMatching(join(root, "test"), maxDepth, isRubyMinitestFileName)) + ); +} + +function isRubyMinitestFileName(entry: string): boolean { + return ( + entry.endsWith("_test.rb") || + (/^test_.+\.rb$/u.test(entry) && !/^test_helpers?\.rb$/u.test(entry)) ); } diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 241c11b..89422d9 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -417,6 +417,7 @@ describe("mapFeatures", () => { const root = await fixtureRoot("clawpatch-map-ruby-test-helper-"); await writeFixture(root, "Gemfile", "source 'https://rubygems.org'\n"); await writeFixture(root, "lib/test_helper.rb", "module TestHelper\nend\n"); + await writeFixture(root, "lib/test_utils.rb", "module TestUtils\nend\n"); await writeFixture(root, "test/test_helper.rb", "require 'minitest/autorun'\n"); const project = await detectProject(root); @@ -426,6 +427,7 @@ describe("mapFeatures", () => { expect(project.detected.commands.test).toBeNull(); expect(result.features.map((feature) => feature.title)).not.toContain("Ruby test suite test"); expect(owned).toContain("lib/test_helper.rb"); + expect(owned).toContain("lib/test_utils.rb"); expect(owned).not.toContain("test/test_helper.rb"); }); diff --git a/src/mappers/ruby.ts b/src/mappers/ruby.ts index 0b8aafb..4b47fbf 100644 --- a/src/mappers/ruby.ts +++ b/src/mappers/ruby.ts @@ -928,7 +928,12 @@ function isRubyTestPath(path: string): boolean { function isRubyMinitestPath(path: string): boolean { const name = basename(path); - return name.endsWith("_test.rb") || (/^test_.+\.rb$/u.test(name) && !isRubyTestHelper(name)); + return ( + name.endsWith("_test.rb") || + (/^test_.+\.rb$/u.test(name) && + !isRubyTestHelper(name) && + (path === name || path.startsWith("test/"))) + ); } function isRubyTestHelper(name: string): boolean { From 5157ce8cf324a3db906a57e5c3c79d08570ee774 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 22:49:24 +0100 Subject: [PATCH 6/9] fix: keep co-located Ruby tests detected --- src/detect.ts | 12 +++++------- src/mapper.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index bda8aec..78c6a52 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -1058,16 +1058,14 @@ async function containsRubySpecFile(root: string, maxDepth: number): Promise { return ( - (await containsFileMatching(root, 0, isRubyMinitestFileName)) || - (await containsFileMatching(join(root, "test"), maxDepth, isRubyMinitestFileName)) + (await containsFileMatching(root, maxDepth, (entry) => entry.endsWith("_test.rb"))) || + (await containsFileMatching(root, 0, isRubyPrefixedMinitestFileName)) || + (await containsFileMatching(join(root, "test"), maxDepth, isRubyPrefixedMinitestFileName)) ); } -function isRubyMinitestFileName(entry: string): boolean { - return ( - entry.endsWith("_test.rb") || - (/^test_.+\.rb$/u.test(entry) && !/^test_helpers?\.rb$/u.test(entry)) - ); +function isRubyPrefixedMinitestFileName(entry: string): boolean { + return /^test_.+\.rb$/u.test(entry) && !/^test_helpers?\.rb$/u.test(entry); } async function containsFileNamed(root: string, name: string, maxDepth: number): Promise { diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 89422d9..024c346 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -431,6 +431,22 @@ describe("mapFeatures", () => { expect(owned).not.toContain("test/test_helper.rb"); }); + it("detects co-located Ruby Minitest suffix tests", async () => { + const root = await fixtureRoot("clawpatch-map-ruby-colocated-minitest-"); + await writeFixture(root, "Gemfile", "source 'https://rubygems.org'\n"); + await writeFixture(root, "lib/fixture.rb", "module Fixture\nend\n"); + await writeFixture(root, "lib/fixture_test.rb", "require 'minitest/autorun'\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const source = result.features.find((feature) => feature.title === "Ruby source lib"); + + expect(project.detected.commands.test).toBe("bundle exec rake test"); + expect(source?.tests).toEqual([ + { path: "lib/fixture_test.rb", command: "bundle exec rake test" }, + ]); + }); + it("ignores generated nested gemspec artifacts", async () => { const root = await fixtureRoot("clawpatch-map-ruby-generated-gemspec-"); await writeFixture(root, "package.json", JSON.stringify({ name: "node-only" })); From 47882141cf9133c905c7b629fdadd349ead3fa34 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 22:55:50 +0100 Subject: [PATCH 7/9] fix: avoid Ruby source detection regressions --- src/detect.ts | 7 +++++-- src/mapper.test.ts | 24 ++++++++++++++++++++++++ src/mappers/ruby.ts | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index 78c6a52..e4691d8 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -991,7 +991,7 @@ async function collectPythonFrameworkScanFiles( } async function containsReviewableRubyFile(root: string): Promise { - if (await containsFileMatching(root, 0, isReviewableRubyFileName)) { + if (await containsFileMatching(root, 0, isRootReviewableRubyFileName)) { return true; } for (const prefix of ["app", "lib"]) { @@ -1010,13 +1010,16 @@ async function containsReviewableRubyFile(root: string): Promise { function isReviewableRubyFileName(entry: string): boolean { return ( entry.endsWith(".rb") && - !entry.startsWith("test_") && !entry.endsWith("_spec.rb") && !entry.endsWith("_test.rb") && !/(?:generated|\.gen)\.rb$/iu.test(entry) ); } +function isRootReviewableRubyFileName(entry: string): boolean { + return isReviewableRubyFileName(entry) && !entry.startsWith("test_"); +} + async function containsRubyExecutableSource(dir: string, remainingDepth: number): Promise { if (remainingDepth < 0 || !(await pathExists(dir))) { return false; diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 024c346..0a68ac0 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -447,6 +447,30 @@ describe("mapFeatures", () => { ]); }); + it("keeps test-prefixed Ruby sources under lib reviewable", async () => { + const root = await fixtureRoot("clawpatch-map-ruby-test-prefixed-source-"); + await writeFixture(root, "lib/test_client.rb", "module TestClient\nend\n"); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const source = result.features.find((feature) => feature.title === "Ruby source lib"); + + expect(project.detected.languages).toContain("ruby"); + expect(source?.ownedFiles.map((ref) => ref.path)).toContain("lib/test_client.rb"); + }); + + it("maps scripts directory Ruby files as source only", async () => { + const root = await fixtureRoot("clawpatch-map-ruby-scripts-source-"); + await writeFixture(root, "Gemfile", "source 'https://rubygems.org'\n"); + await writeFixture(root, "scripts/support.rb", "module Support\nend\n"); + + const result = await mapFeatures(root, await detectProject(root), []); + const titles = result.features.map((feature) => feature.title); + + expect(titles).toContain("Ruby source scripts"); + expect(titles).not.toContain("Ruby CLI command support.rb"); + }); + it("ignores generated nested gemspec artifacts", async () => { const root = await fixtureRoot("clawpatch-map-ruby-generated-gemspec-"); await writeFixture(root, "package.json", JSON.stringify({ name: "node-only" })); diff --git a/src/mappers/ruby.ts b/src/mappers/ruby.ts index 4b47fbf..60ca4d3 100644 --- a/src/mappers/ruby.ts +++ b/src/mappers/ruby.ts @@ -33,7 +33,7 @@ type RubyProjectInfo = { const metadataFiles = ["Gemfile", "gems.rb", "Rakefile", "config.ru"] as const; const sourceRoots = ["app", "lib", "scripts"] as const; -const executableRoots = ["exe", "bin", "script", "scripts"] as const; +const executableRoots = ["exe", "bin", "script"] as const; const railsBinstubs = new Set(["bundle", "rails", "rake", "setup", "spring", "yarn"]); const sourceGroupMaxOwnedFiles = 12; const sourceGroupMaxTests = 8; From 1201af5434b9b115822929cf63fde122b9b1af60 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 23:02:36 +0100 Subject: [PATCH 8/9] fix: preserve Ruby test-prefixed stems --- src/mapper.test.ts | 5 +++++ src/mappers/ruby.ts | 20 +++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 0a68ac0..9ce61f6 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -449,7 +449,9 @@ describe("mapFeatures", () => { it("keeps test-prefixed Ruby sources under lib reviewable", async () => { const root = await fixtureRoot("clawpatch-map-ruby-test-prefixed-source-"); + await writeFixture(root, "Gemfile", "source 'https://rubygems.org'\n"); await writeFixture(root, "lib/test_client.rb", "module TestClient\nend\n"); + await writeFixture(root, "test/test_client_test.rb", "require 'minitest/autorun'\n"); const project = await detectProject(root); const result = await mapFeatures(root, project, []); @@ -457,6 +459,9 @@ describe("mapFeatures", () => { expect(project.detected.languages).toContain("ruby"); expect(source?.ownedFiles.map((ref) => ref.path)).toContain("lib/test_client.rb"); + expect(source?.tests).toEqual([ + { path: "test/test_client_test.rb", command: "bundle exec rake test" }, + ]); }); it("maps scripts directory Ruby files as source only", async () => { diff --git a/src/mappers/ruby.ts b/src/mappers/ruby.ts index 60ca4d3..9426e19 100644 --- a/src/mappers/ruby.ts +++ b/src/mappers/ruby.ts @@ -901,17 +901,27 @@ function associatedTests( const dirs = new Set(files.map((file) => dirname(file))); return tests .filter((test) => { - const testStem = basename(test) - .replace(/_spec\.rb$/u, "") - .replace(/_test\.rb$/u, "") - .replace(/\.rb$/u, "") - .replace(/^test_/u, ""); + const testStem = rubyTestStem(test); return [...dirs].some((dir) => pathMatchesPrefix(test, dir)) || fileStems.has(testStem); }) .slice(0, sourceGroupMaxTests) .map((path) => ({ path, command: commandForTest(path) })); } +function rubyTestStem(path: string): string { + const name = basename(path); + if (name.endsWith("_spec.rb")) { + return name.replace(/_spec\.rb$/u, ""); + } + if (name.endsWith("_test.rb")) { + return name.replace(/_test\.rb$/u, ""); + } + if (/^test_.+\.rb$/u.test(name)) { + return name.replace(/^test_/u, "").replace(/\.rb$/u, ""); + } + return name.replace(/\.rb$/u, ""); +} + function isReviewableRubySourceFile(path: string): boolean { return ( path.endsWith(".rb") && From b4a3e2770e91c452afd855f9c4f91f26d8e23cf4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 16 May 2026 23:08:37 +0100 Subject: [PATCH 9/9] fix: detect nested prefixed Ruby tests --- src/detect.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- src/mapper.test.ts | 9 +++++++++ src/mappers/ruby.ts | 2 +- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/detect.ts b/src/detect.ts index e4691d8..00ab977 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -1062,8 +1062,7 @@ async function containsRubySpecFile(root: string, maxDepth: number): Promise { return ( (await containsFileMatching(root, maxDepth, (entry) => entry.endsWith("_test.rb"))) || - (await containsFileMatching(root, 0, isRubyPrefixedMinitestFileName)) || - (await containsFileMatching(join(root, "test"), maxDepth, isRubyPrefixedMinitestFileName)) + (await containsRubyPrefixedMinitestFile(root, maxDepth)) ); } @@ -1071,6 +1070,45 @@ function isRubyPrefixedMinitestFileName(entry: string): boolean { return /^test_.+\.rb$/u.test(entry) && !/^test_helpers?\.rb$/u.test(entry); } +async function containsRubyPrefixedMinitestFile( + dir: string, + remainingDepth: number, + relativeDir = "", +): Promise { + if (remainingDepth < 0 || !(await pathExists(dir))) { + return false; + } + const dirInfo = await lstat(dir); + if (!dirInfo.isDirectory() || dirInfo.isSymbolicLink()) { + return false; + } + for (const entry of await readdir(dir)) { + if (shouldSkipSearchEntry(entry)) { + continue; + } + const full = join(dir, entry); + const path = relativeDir === "" ? entry : `${relativeDir}/${entry}`; + const info = await lstat(full); + if (info.isSymbolicLink()) { + continue; + } + if ( + info.isFile() && + isRubyPrefixedMinitestFileName(entry) && + (relativeDir === "" || /(^|\/)test$/u.test(relativeDir)) + ) { + return true; + } + if ( + info.isDirectory() && + (await containsRubyPrefixedMinitestFile(full, remainingDepth - 1, path)) + ) { + return true; + } + } + return false; +} + async function containsFileNamed(root: string, name: string, maxDepth: number): Promise { return containsFileMatching(root, maxDepth, (entry) => entry === name); } diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 9ce61f6..4a63252 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -550,19 +550,28 @@ describe("mapFeatures", () => { "Gem::Specification.new do |spec|\n spec.name = 'engine'\n spec.add_dependency 'rails'\nend\n", ); await writeFixture(root, "engine/lib/engine.rb", "module Engine\nend\n"); + await writeFixture(root, "engine/test/test_engine.rb", "require 'minitest/autorun'\n"); await writeFixture(root, "config/application.rb", "module NotRails\nend\n"); await writeFixture(root, "app/assets/admin.ts", "export const admin = true;\n"); const project = await detectProject(root); const result = await mapFeatures(root, project, []); const titles = result.features.map((feature) => feature.title); + const rubySource = result.features.find( + (feature) => feature.title === "Ruby source engine/lib", + ); const nodeAsset = result.features.find((feature) => feature.ownedFiles.some((file) => file.path === "app/assets/admin.ts"), ); expect(project.detected.languages).toContain("ruby"); expect(project.detected.frameworks).not.toContain("rails"); + expect(project.detected.commands.test).toBe("rake test"); expect(titles).not.toContain("Rails application configuration"); + expect(titles).toContain("Ruby test suite engine/test"); + expect(rubySource?.tests).toEqual([ + { path: "engine/test/test_engine.rb", command: "rake test" }, + ]); expect(nodeAsset?.title).toBe("Node source app"); }); diff --git a/src/mappers/ruby.ts b/src/mappers/ruby.ts index 9426e19..3180cbc 100644 --- a/src/mappers/ruby.ts +++ b/src/mappers/ruby.ts @@ -942,7 +942,7 @@ function isRubyMinitestPath(path: string): boolean { name.endsWith("_test.rb") || (/^test_.+\.rb$/u.test(name) && !isRubyTestHelper(name) && - (path === name || path.startsWith("test/"))) + (path === name || /(^|\/)test\//u.test(path))) ); }