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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

- 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 selected package script mapping for Node workspace packages.
- Detected Java/Kotlin language and default Gradle build/test commands for root Gradle projects.
- Added FastAPI route feature mapping and kept root/web Python project detection in sync.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ validation commands and records a patch attempt under `.clawpatch/`.
- Java/Kotlin Gradle source groups and root Gradle build/test commands
- JVM semantic roles from Java code evidence such as annotations, imports,
interfaces, inheritance, and method signatures
- Ruby project metadata, executables, source groups, RSpec/Minitest suites
- Rust `src/main.rs`, `src/bin/*.rs`, `src/lib.rs`, `crates/*`, and
`tests/*.rs`
- Python project metadata, console scripts, bounded source groups, pytest suites,
Expand Down
7 changes: 7 additions & 0 deletions docs/feature-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Supported deterministic mappers today:
- Python project metadata, console scripts, root app files, bounded source groups,
pytest suites, and Flask/FastAPI routes
- JVM semantic role groups from Java annotations, imports, inheritance, interfaces, and method signatures
- Ruby project metadata, executables, source groups, RSpec/Minitest suites,
Rails configs, routes, views, assets, and database files
- Rust Cargo commands, libraries, workspace crates, and integration tests
- SwiftPM executable targets, library targets, and test suites
- nested SwiftPM packages
Expand Down Expand Up @@ -68,6 +70,11 @@ handlers, and FastAPI `@*.get(...)` / `@*.api_route(...)` handlers. Flask and
FastAPI route methods are read from list, tuple, or set literals. FastAPI paths
can be positional strings or literal `path=` keywords.

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.

Known gaps:

- no Express/Fastify/Hono route mapper yet
Expand Down
1 change: 1 addition & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ This discovers reviewable features:
- Java/Kotlin Gradle modules
- Python packages, console scripts, Flask routes, and pytest suites
- JVM semantic role groups
- Ruby packages, Rails apps, executables, and tests
- Rust crates and binaries
- SwiftPM targets and tests
- Config files
Expand Down
99 changes: 99 additions & 0 deletions src/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ async function languageDefaultCommands(
) {
return gradleDefaultCommands(root);
}
if (languages.includes("ruby")) {
return rubyDefaultCommands(root);
}

return {
typecheck: null,
Expand Down Expand Up @@ -241,10 +244,14 @@ async function detectPackageManagers(root: string): Promise<string[]> {
if (!found.some((name) => pythonPackageManagers.has(name)) && (await isPythonProject(root))) {
found.push((await pathExists(join(root, "requirements.txt"))) ? "pip" : "python");
}
if ((await isRubyProject(root)) && !found.some((name) => rubyPackageManagers.has(name))) {
found.push((await pathExists(join(root, "Gemfile"))) ? "bundler" : "ruby");
}
return found;
}

const pythonPackageManagers = new Set(["uv", "poetry", "pdm", "hatch", "pip", "python"]);
const rubyPackageManagers = new Set(["bundler", "ruby"]);

async function isRootGradleProject(root: string): Promise<boolean> {
return (
Expand Down Expand Up @@ -336,6 +343,39 @@ function pythonRunCommand(runner: string | null, command: string): string {
return command;
}

async function rubyDefaultCommands(root: string): Promise<ProjectCommands> {
const source = await rubyDependencySource(root);
const hasBundle = await pathExists(join(root, "Gemfile"));
const hasRspec = /\brspec\b/iu.test(source) || (await containsRubySpecFile(root, 5));
const hasMinitest = /\bminitest\b/iu.test(source) || (await containsRubyTestFile(root, 5));
const hasRubocop =
/\brubocop\b/iu.test(source) ||
(await pathExists(join(root, ".rubocop.yml"))) ||
(await pathExists(join(root, ".rubocop_todo.yml")));
const run = hasBundle ? "bundle exec " : "";
return {
typecheck: null,
lint: hasRubocop ? `${run}rubocop` : null,
format: null,
test: hasRspec ? `${run}rspec` : hasMinitest ? `${run}rake test` : null,
};
}

async function rubyDependencySource(root: string): Promise<string> {
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 entry of await readdir(root).catch(() => [])) {
if (entry.endsWith(".gemspec")) {
chunks.push(await readFile(join(root, entry), "utf8"));
}
}
return chunks.join("\n");
}

async function pythonProjectInfo(root: string): Promise<PythonProjectInfo> {
const info: PythonProjectInfo = {
dependencies: new Set(),
Expand Down Expand Up @@ -713,9 +753,29 @@ async function detectFrameworks(root: string, pkg: PackageJson | null): Promise<
}
}
}
for (const name of await detectRubyFrameworks(root)) {
if (!frameworks.includes(name)) {
frameworks.push(name);
}
}
return uniqueStrings(frameworks);
}

async function detectRubyFrameworks(root: string): Promise<string[]> {
const source = await rubyDependencySource(root);
const frameworks: string[] = [];
for (const name of ["jekyll", "rails", "sinatra"]) {
if (new RegExp(`\\b${name}\\b`, "iu").test(source)) {
frameworks.push(name);
}
}
return frameworks;
}

function uniqueStrings(values: string[]): string[] {
return [...new Set(values)];
}

function dependencyNames(pkg: PackageJson | null): Set<string> {
const names = new Set<string>();
for (const field of [pkg?.dependencies, pkg?.devDependencies]) {
Expand All @@ -740,6 +800,10 @@ async function detectLanguages(root: string): Promise<string[]> {
["python", "setup.py"],
["python", "setup.cfg"],
["python", "requirements.txt"],
["ruby", "Gemfile"],
["ruby", "gems.rb"],
["ruby", "Rakefile"],
["ruby", "config.ru"],
];
const languages: string[] = [];
for (const [language, file] of checks) {
Expand All @@ -753,6 +817,9 @@ async function detectLanguages(root: string): Promise<string[]> {
if (!languages.includes("java") && (await containsReviewableJavaFile(root))) {
languages.push("java");
}
if (!languages.includes("ruby") && (await isRubyProject(root))) {
languages.push("ruby");
}
if (
!languages.includes("swift") &&
((await containsFileNamed(root, "Package.swift", 5)) ||
Expand Down Expand Up @@ -800,6 +867,19 @@ async function isPythonProject(root: string): Promise<boolean> {
);
}

async function isRubyProject(root: string): Promise<boolean> {
if (
(await pathExists(join(root, "Gemfile"))) ||
(await pathExists(join(root, "gems.rb"))) ||
(await pathExists(join(root, "Rakefile"))) ||
(await pathExists(join(root, "config.ru"))) ||
(await containsFileWithExtension(root, ".gemspec", 1))
) {
return true;
}
return containsReviewableRubyFile(root);
}

async function containsReviewablePythonFile(root: string): Promise<boolean> {
if (await containsRootReviewablePythonFile(root)) {
return true;
Expand Down Expand Up @@ -887,6 +967,23 @@ async function collectPythonFrameworkScanFiles(
}
}

async function containsReviewableRubyFile(root: string): Promise<boolean> {
for (const prefix of ["app", "lib", "scripts", "exe", "bin"]) {
if (await containsFileWithExtension(join(root, prefix), ".rb", 4)) {
return true;
}
}
return false;
}

async function containsRubySpecFile(root: string, maxDepth: number): Promise<boolean> {
return containsFileMatching(root, maxDepth, (entry) => entry.endsWith("_spec.rb"));
}

async function containsRubyTestFile(root: string, maxDepth: number): Promise<boolean> {
return containsFileMatching(root, maxDepth, (entry) => entry.endsWith("_test.rb"));
}

async function containsFileNamed(root: string, name: string, maxDepth: number): Promise<boolean> {
return containsFileMatching(root, maxDepth, (entry) => entry === name);
}
Expand Down Expand Up @@ -947,6 +1044,8 @@ function shouldSkipSearchEntry(entry: string): boolean {
".mypy_cache",
".ruff_cache",
".pytest_cache",
".bundle",
"vendor",
"fixtures",
"__fixtures__",
"testdata",
Expand Down
Loading