diff --git a/CHANGELOG.md b/CHANGELOG.md index f581c2b..fdd9023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Improved Node route mapping to preserve literal Express and Hono mount prefixes, thanks @rohitjavvadi. - Improved Flask route mapping to preserve static blueprint URL prefixes, thanks @rohitjavvadi. - Improved Django route mapping to preserve literal `include()` route prefixes, thanks @rohitjavvadi. +- Added conservative Rails route mapping for literal root and HTTP verb routes, thanks @rohitjavvadi. - Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi. - Fixed Laravel route mapping to include array-style `Route::group` prefixes, thanks @rohitjavvadi. - Fixed Fastify route-object mapping to emit static method arrays while ignoring dynamic entries, thanks @rohitjavvadi. diff --git a/docs/feature-mapping.md b/docs/feature-mapping.md index 8321780..e8c7172 100644 --- a/docs/feature-mapping.md +++ b/docs/feature-mapping.md @@ -171,7 +171,8 @@ from literal route strings and simple named regex groups, and literal Python command detection covers pytest, ruff, mypy, pyright, and black. Ruby mapping covers project metadata, executables, source groups, RSpec and -Minitest suites, and Rails app structure. Rails legacy `config/secrets.yml`, +Minitest suites, Rails app structure, and literal Rails root and HTTP verb +routes. 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. diff --git a/src/mapper.test.ts b/src/mapper.test.ts index a401302..0f9aea0 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1651,6 +1651,234 @@ describe("mapFeatures", () => { expect(referencedFiles).not.toContain("config/initializers/secret_token.rb"); }); + it("maps literal Rails route declarations", async () => { + const root = await fixtureRoot("clawpatch-map-rails-routes-"); + await writeFixture(root, "Gemfile", "source 'https://rubygems.org'\ngem 'rails'\n"); + await writeFixture(root, "config/application.rb", "module FixtureRailsRoutes\nend\n"); + await writeFixture( + root, + "config/routes.rb", + [ + "get '/outside-before', to: 'outside#show'", + "def helper_route", + " get '/helper-outside', to: 'outside#show'", + "end", + "Rails.application.routes.draw do", + " root 'home#index'", + " get '/admin/users', to: 'admin/users#index'", + " post '/sessions', 'sessions#create'", + " put '/profiles/:id', to: 'profiles#update'", + " patch '/accounts/:id', to: 'accounts#update'", + " delete '/sessions/:id', to: 'sessions#destroy'", + " get dynamic_path, to: 'ignored#index'", + " get '/wildcards/*path', to: 'files#show'", + " get '/shorthand'", + " namespace :admin do", + " get '/scoped-users', to: 'users#index'", + " [1].each do |item|", + " item.to_s", + " end", + " get '/leaked', to: 'leaked#index'", + " get '/do-not-enter', to: 'gates#show'", + " end", + " resources :posts do", + " get '/featured', to: 'posts#featured'", + " member do", + " get '/preview', to: 'posts#preview'", + " end", + " end", + " scope path: '/api' do", + " get '/health', to: 'health#show'", + " end", + " scope(path: '/brace') {", + " get '/brace-health', to: 'health#show'", + " token = /\\}/", + " get '/brace-leaked', to: 'leaked#index'", + " }", + " get '/constrained', to: 'constrained#index', constraints: {", + " subdomain: 'api'", + " }", + " get '/regex-constrained', to: 'regex#index', constraints: { token: /\\{/ }", + " get '/hash-rocket-regex', to: 'regex#index', constraints: { :token => /\\{/ }", + " get '/public', to: 'public#index'", + " constraints subdomain: 'api' do", + " get '/constraint-health', to: 'health#show'", + " end", + " match '/legacy', to: 'legacy#show', via: :get", + " resources :posts", + "end", + "get '/outside-after', to: 'outside#show'", + ].join("\n"), + ); + await writeFixture( + root, + "app/controllers/home_controller.rb", + "class HomeController < ApplicationController\nend\n", + ); + await writeFixture( + root, + "app/controllers/admin/users_controller.rb", + "module Admin\n class UsersController < ApplicationController\n end\nend\n", + ); + await writeFixture( + root, + "app/controllers/sessions_controller.rb", + "class SessionsController < ApplicationController\nend\n", + ); + await writeFixture( + root, + "app/controllers/profiles_controller.rb", + "class ProfilesController < ApplicationController\nend\n", + ); + await writeFixture( + root, + "app/controllers/accounts_controller.rb", + "class AccountsController < ApplicationController\nend\n", + ); + await writeFixture( + root, + "test/controllers/sessions_controller_test.rb", + "class SessionsControllerTest\nend\n", + ); + + const project = await detectProject(root); + const result = await mapFeatures(root, project, []); + const titles = result.features.map((feature) => feature.title); + const rootRoute = result.features.find((feature) => feature.title === "Rails route GET /"); + const adminRoute = result.features.find( + (feature) => feature.title === "Rails route GET /admin/users", + ); + const sessionRoute = result.features.find( + (feature) => feature.title === "Rails route POST /sessions", + ); + const profileRoute = result.features.find( + (feature) => feature.title === "Rails route PUT /profiles/:id", + ); + const accountRoute = result.features.find( + (feature) => feature.title === "Rails route PATCH /accounts/:id", + ); + const deleteSessionRoute = result.features.find( + (feature) => feature.title === "Rails route DELETE /sessions/:id", + ); + const constrainedRoute = result.features.find( + (feature) => feature.title === "Rails route GET /constrained", + ); + const regexConstrainedRoute = result.features.find( + (feature) => feature.title === "Rails route GET /regex-constrained", + ); + const hashRocketRegexRoute = result.features.find( + (feature) => feature.title === "Rails route GET /hash-rocket-regex", + ); + + expect(project.detected.frameworks).toContain("rails"); + expect(titles).toEqual( + expect.arrayContaining([ + "Rails route GET /", + "Rails route GET /admin/users", + "Rails route POST /sessions", + "Rails route PUT /profiles/:id", + "Rails route PATCH /accounts/:id", + "Rails route DELETE /sessions/:id", + "Rails route GET /constrained", + "Rails route GET /regex-constrained", + "Rails route GET /hash-rocket-regex", + ]), + ); + expect(titles).not.toContain("Rails route GET /wildcards/*path"); + expect(titles).not.toContain("Rails route GET /shorthand"); + expect(titles).not.toContain("Rails route GET /scoped-users"); + expect(titles).not.toContain("Rails route GET /leaked"); + expect(titles).not.toContain("Rails route GET /do-not-enter"); + expect(titles).not.toContain("Rails route GET /featured"); + expect(titles).not.toContain("Rails route GET /preview"); + expect(titles).not.toContain("Rails route GET /health"); + expect(titles).not.toContain("Rails route GET /brace-health"); + expect(titles).not.toContain("Rails route GET /brace-leaked"); + expect(titles).not.toContain("Rails route GET /constraint-health"); + expect(titles).not.toContain("Rails route GET /legacy"); + expect(titles).not.toContain("Rails route GET /outside-before"); + expect(titles).not.toContain("Rails route GET /helper-outside"); + expect(titles).not.toContain("Rails route GET /outside-after"); + expect(titles).toContain("Rails route GET /public"); + expect(rootRoute?.source).toBe("rails-route"); + expect(rootRoute?.entrypoints[0]).toMatchObject({ + path: "app/controllers/home_controller.rb", + symbol: "home#index", + route: "GET /", + }); + expect(adminRoute?.entrypoints[0]).toMatchObject({ + path: "app/controllers/admin/users_controller.rb", + symbol: "admin/users#index", + route: "GET /admin/users", + }); + expect(adminRoute?.contextFiles).toContainEqual({ + path: "config/routes.rb", + reason: "route definition", + }); + expect(adminRoute?.trustBoundaries).toContain("auth"); + expect(sessionRoute?.entrypoints[0]?.route).toBe("POST /sessions"); + expect(sessionRoute?.tests).toEqual([ + { path: "test/controllers/sessions_controller_test.rb", command: "bundle exec rake test" }, + ]); + expect(sessionRoute?.trustBoundaries).toContain("auth"); + expect(profileRoute?.entrypoints[0]?.route).toBe("PUT /profiles/:id"); + expect(accountRoute?.entrypoints[0]?.route).toBe("PATCH /accounts/:id"); + expect(deleteSessionRoute?.entrypoints[0]?.route).toBe("DELETE /sessions/:id"); + expect(constrainedRoute?.entrypoints[0]?.route).toBe("GET /constrained"); + expect(regexConstrainedRoute?.entrypoints[0]?.route).toBe("GET /regex-constrained"); + expect(hashRocketRegexRoute?.entrypoints[0]?.route).toBe("GET /hash-rocket-regex"); + }); + + symlinkIt("keeps Rails route files and handlers inside the repository", async () => { + const root = await fixtureRoot("clawpatch-map-rails-route-symlinks-"); + const external = await fixtureRoot("clawpatch-map-rails-route-external-"); + await writeFixture(root, "Gemfile", "source 'https://rubygems.org'\ngem 'rails'\n"); + await writeFixture(root, "config/application.rb", "module FixtureRailsRouteSymlinks\nend\n"); + await writeFixture( + external, + "routes.rb", + "Rails.application.routes.draw do\n get '/external', to: 'external#show'\nend\n", + ); + await symlink(join(external, "routes.rb"), join(root, "config/routes.rb")); + + const skippedProject = await detectProject(root); + const skipped = await mapFeatures(root, skippedProject, []); + expect(skipped.features.map((feature) => feature.title)).not.toContain( + "Rails route GET /external", + ); + + const safeRoot = await fixtureRoot("clawpatch-map-rails-route-handler-symlink-"); + await writeFixture(safeRoot, "Gemfile", "source 'https://rubygems.org'\ngem 'rails'\n"); + await writeFixture( + safeRoot, + "config/application.rb", + "module FixtureRailsRouteHandlerSymlink\nend\n", + ); + await writeFixture( + safeRoot, + "config/routes.rb", + "Rails.application.routes.draw do\n get '/unsafe', to: 'unsafe#show'\nend\n", + ); + await mkdir(join(safeRoot, "app/controllers"), { recursive: true }); + await writeFixture(external, "unsafe_controller.rb", "class UnsafeController\nend\n"); + await symlink( + join(external, "unsafe_controller.rb"), + join(safeRoot, "app/controllers/unsafe_controller.rb"), + ); + + const project = await detectProject(safeRoot); + const result = await mapFeatures(safeRoot, project, []); + const route = result.features.find((feature) => feature.title === "Rails route GET /unsafe"); + expect(route?.entrypoints[0]).toMatchObject({ + path: "config/routes.rb", + symbol: "unsafe#show", + route: "GET /unsafe", + }); + expect(route?.ownedFiles).toEqual([ + { path: "config/routes.rb", reason: "rails route declaration" }, + ]); + }); + it("maps workspace packages and splits large Node source groups", async () => { const root = await fixtureRoot("clawpatch-node-workspace-map-"); await writeFixture( diff --git a/src/mappers/ruby.ts b/src/mappers/ruby.ts index 3180cbc..a9ce306 100644 --- a/src/mappers/ruby.ts +++ b/src/mappers/ruby.ts @@ -148,7 +148,7 @@ export async function rubySeeds(root: string): Promise { } seeds.push(...(await jekyllSeeds(root, info))); - seeds.push(...(await railsSeeds(root, info))); + seeds.push(...(await railsSeeds(root, info, testFiles, commandForTest, testCommand))); for (const testSuite of standaloneTestSuites(testFiles, commandForTest)) { seeds.push(testSuite); @@ -344,13 +344,26 @@ async function isJekyllSite(root: string, info: RubyProjectInfo): Promise { +type RailsRoute = { + method: string; + path: string; + target: string; +}; + +async function railsSeeds( + root: string, + info: RubyProjectInfo, + testFiles: string[], + commandForTest: (path: string) => string | null, + testCommand: string | null, +): Promise { if (!(await isRailsApp(root, info))) { return []; } const trustBoundaries = rubyTrustBoundaries("rails app", info.dependencies); const seeds: FeatureSeed[] = []; const configFiles = await railsConfigFiles(root); + const routeFile = "config/routes.rb"; if (configFiles.length > 0) { seeds.push({ title: "Rails application configuration", @@ -370,6 +383,42 @@ async function railsSeeds(root: string, info: RubyProjectInfo): Promise ({ path: test.path, reason: "associated test" })), + ]), + tests, + tags: ["ruby", "rails", "route"], + trustBoundaries: railsRouteTrustBoundaries(route, trustBoundaries), + testCommand, + skipNearbyTests: true, + }); + } + } + const dbFiles = await railsDatabaseFiles(root); for (const group of partitionSourceFiles("db", dbFiles, sourceGroupMaxOwnedFiles)) { seeds.push({ @@ -439,6 +488,259 @@ async function railsSeeds(root: string, info: RubyProjectInfo): Promise(); + let drawBlockDepth = 0; + let skippedBlockDepth = 0; + for (const line of stripRubyComments(source).split("\n")) { + const blockDelta = railsLineBlockDelta(line); + if (drawBlockDepth === 0) { + if (startsRailsRoutesDrawBlock(line) && blockDelta > 0) { + drawBlockDepth = blockDelta; + } + continue; + } + + if (skippedBlockDepth > 0) { + skippedBlockDepth = Math.max(0, skippedBlockDepth + blockDelta); + drawBlockDepth = Math.max(0, drawBlockDepth + blockDelta); + continue; + } + const route = parseRailsRouteLine(line); + if (route !== null) { + const key = [route.method, route.path, route.target].join("\0"); + if (!seen.has(key)) { + seen.add(key); + routes.push(route); + } + } + if (blockDelta === 0) { + continue; + } + + if (blockDelta > 0) { + skippedBlockDepth = blockDelta; + } + drawBlockDepth = Math.max(0, drawBlockDepth + blockDelta); + } + return routes; +} + +function startsRailsRoutesDrawBlock(line: string): boolean { + return /\.routes\.draw\b/u.test(line); +} + +function railsLineBlockDelta(line: string): number { + const code = rubyLineWithoutStrings(line); + return rubyTokenCount(code, "do") - rubyTokenCount(code, "end") + rubyBraceDelta(code); +} + +function rubyTokenCount(source: string, token: string): number { + const matches = source.match(new RegExp(`\\b${token}\\b`, "gu")); + return matches?.length ?? 0; +} + +function rubyBraceDelta(source: string): number { + let delta = 0; + for (const char of source) { + if (char === "{") { + delta += 1; + } else if (char === "}") { + delta -= 1; + } + } + return delta; +} + +function rubyLineWithoutStrings(line: string): string { + let code = ""; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + if (char === "/" && rubyRegexLiteralStart(line, index)) { + const end = rubyRegexLiteralEnd(line, index); + code += " ".repeat(end - index + 1); + index = end; + continue; + } + if (char !== "'" && char !== '"') { + code += char; + continue; + } + code += " "; + const quote = char; + index += 1; + for (; index < line.length; index += 1) { + const next = line[index]; + code += " "; + if (next === "\\") { + index += 1; + code += " "; + } else if (next === quote) { + break; + } + } + } + return code; +} + +function rubyRegexLiteralStart(line: string, index: number): boolean { + const before = line.slice(0, index).trimEnd(); + const previous = before.at(-1); + return previous === undefined || /[([{=,:!&|?>]/u.test(previous); +} + +function rubyRegexLiteralEnd(line: string, start: number): number { + let escaped = false; + let inCharacterClass = false; + for (let index = start + 1; index < line.length; index += 1) { + const char = line[index]; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === "[") { + inCharacterClass = true; + } else if (char === "]") { + inCharacterClass = false; + } else if (char === "/" && !inCharacterClass) { + return index; + } + } + return start; +} + +function parseRailsRouteLine(line: string): RailsRoute | null { + const rootArgs = /^\s*root\b\s*(?:\(\s*)?(.*)$/u.exec(line)?.[1]; + if (rootArgs !== undefined) { + const target = railsRouteTarget(rootArgs, true); + return target === null ? null : { method: "GET", path: "/", target }; + } + + const route = /^\s*(get|post|put|patch|delete)\b\s*(?:\(\s*)?(.*)$/iu.exec(line); + if (route === null) { + return null; + } + const method = route[1]?.toUpperCase(); + const args = route[2]; + if (method === undefined || args === undefined) { + return null; + } + const pathLiteral = consumeRubyStringLiteral(args); + if (pathLiteral === null) { + return null; + } + const path = normalizeRailsRoutePath(pathLiteral.value); + const target = railsRouteTarget(pathLiteral.rest, true); + return path === null || target === null ? null : { method, path, target }; +} + +function railsRouteTarget(args: string, allowPositional: boolean): string | null { + const toMatch = /(?:^|[,\s])to:\s*/u.exec(args); + if (toMatch !== null) { + const literal = consumeRubyStringLiteral(args.slice(toMatch.index + toMatch[0].length)); + return literal === null ? null : normalizeRailsRouteTarget(literal.value); + } + + if (!allowPositional) { + return null; + } + const positionalArgs = args.trimStart().replace(/^,\s*/u, ""); + const literal = consumeRubyStringLiteral(positionalArgs); + return literal === null ? null : normalizeRailsRouteTarget(literal.value); +} + +function consumeRubyStringLiteral(source: string): { value: string; rest: string } | null { + const leadingWhitespace = /^\s*/u.exec(source)?.[0].length ?? 0; + const trimmed = source.slice(leadingWhitespace); + const quote = trimmed[0]; + if (quote === "'" || quote === '"') { + let escaped = false; + for (let index = 1; index < trimmed.length; index += 1) { + const char = trimmed[index]; + if (escaped) { + escaped = false; + } else if (char === "\\") { + escaped = true; + } else if (char === quote) { + return { + value: trimmed.slice(1, index), + rest: trimmed.slice(index + 1), + }; + } + } + return null; + } + + const percent = /^%[qQ]([<{[(]|[^A-Za-z0-9\s])/u.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 + : { + value: rest.slice(0, end), + rest: rest.slice(end + 1), + }; +} + +function normalizeRailsRoutePath(path: string): string | null { + const trimmed = path.trim(); + if (trimmed.length === 0 || trimmed.includes("#{") || trimmed.includes("*")) { + return null; + } + const normalized = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized; +} + +function normalizeRailsRouteTarget(target: string): string | null { + const trimmed = target.trim(); + return /^[A-Za-z0-9_/]+#[A-Za-z_][A-Za-z0-9_!?]*$/u.test(trimmed) ? trimmed : null; +} + +async function railsRouteHandlerPath(root: string, target: string): Promise { + const controller = target.split("#")[0]; + if (controller === undefined) { + return null; + } + const path = `app/controllers/${controller}_controller.rb`; + return (await isSafeFile(root, join(root, path))) ? path : null; +} + +function railsRouteTrustBoundaries( + route: RailsRoute, + appTrustBoundaries: TrustBoundary[], +): TrustBoundary[] { + const boundaries = new Set([ + ...appTrustBoundaries, + "user-input", + "network", + "serialization", + ]); + if ( + route.method !== "GET" || + /(^|\/)(account|admin|auth|login|logout|oauth|password|session|token)(\/|$)/iu.test( + route.path, + ) || + /(^|\/)(admin|auth|oauth|sessions?)(\/|#|$)/iu.test(route.target) + ) { + boundaries.add("auth"); + } + if (/(^|\/)(callback|integration|webhook)(\/|$)/iu.test(route.path)) { + boundaries.add("external-api"); + } + return [...boundaries]; +} + async function isRailsApp(root: string, info: RubyProjectInfo): Promise { return info.dependencies.has("rails") && (await pathExists(join(root, "config/application.rb"))); } @@ -998,3 +1300,16 @@ function uniquePaths(paths: string[]): string[] { function uniquePathsInOrder(paths: string[]): string[] { return [...new Set(paths)]; } + +function uniqueFileRefs(refs: SeedFileRef[]): SeedFileRef[] { + const seen = new Set(); + const uniqueRefs: SeedFileRef[] = []; + for (const ref of refs) { + const key = `${ref.path}\0${ref.reason}`; + if (!seen.has(key)) { + seen.add(key); + uniqueRefs.push(ref); + } + } + return uniqueRefs; +}