From eb0f1f0fdf43e568a630723293065380d808cc82 Mon Sep 17 00:00:00 2001 From: Rohit Date: Thu, 21 May 2026 23:19:46 +0530 Subject: [PATCH 1/3] feat(mapper): map literal Rails routes --- src/mapper.test.ts | 193 ++++++++++++++++++++++++++++++++ src/mappers/ruby.ts | 265 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 456 insertions(+), 2 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 9b324b4..88dc68a 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1651,6 +1651,199 @@ 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", + [ + "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", + " 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", + ].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", + ); + + 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", + ]), + ); + 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 /constraint-health"); + expect(titles).not.toContain("Rails route GET /legacy"); + 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"); + }); + + 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..afe076b 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,205 @@ async function railsSeeds(root: string, info: RubyProjectInfo): Promise(); + let skippedBlockDepth = 0; + for (const line of stripRubyComments(source).split("\n")) { + const blockDelta = railsLineBlockDelta(line); + if (skippedBlockDepth > 0) { + skippedBlockDepth = Math.max(0, skippedBlockDepth + blockDelta); + continue; + } + if (blockDelta === 0) { + 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); + } + } + continue; + } + + if (!startsRailsRoutesDrawBlock(line) && blockDelta > 0) { + skippedBlockDepth = 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"); +} + +function rubyTokenCount(source: string, token: string): number { + const matches = source.match(new RegExp(`\\b${token}\\b`, "gu")); + return matches?.length ?? 0; +} + +function rubyLineWithoutStrings(line: string): string { + let code = ""; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + 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 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 +1246,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; +} From 91fec30c8be0d42cf7a7ff77fcf30b22ff571617 Mon Sep 17 00:00:00 2001 From: Rohit Date: Thu, 21 May 2026 23:57:00 +0530 Subject: [PATCH 2/3] fix(mapper): handle brace route blocks --- src/mapper.test.ts | 27 ++++++++++++++++++++ src/mappers/ruby.ts | 62 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 88dc68a..be4592c 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1686,6 +1686,16 @@ describe("mapFeatures", () => { " 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'", @@ -1745,6 +1755,15 @@ describe("mapFeatures", () => { 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( @@ -1755,6 +1774,9 @@ describe("mapFeatures", () => { "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"); @@ -1765,6 +1787,8 @@ describe("mapFeatures", () => { 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).toContain("Rails route GET /public"); @@ -1792,6 +1816,9 @@ describe("mapFeatures", () => { 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 () => { diff --git a/src/mappers/ruby.ts b/src/mappers/ruby.ts index afe076b..ca663f5 100644 --- a/src/mappers/ruby.ts +++ b/src/mappers/ruby.ts @@ -498,15 +498,15 @@ function railsRouteDeclarations(source: string): RailsRoute[] { skippedBlockDepth = Math.max(0, skippedBlockDepth + blockDelta); continue; } - if (blockDelta === 0) { - 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); - } + 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; } @@ -523,7 +523,7 @@ function startsRailsRoutesDrawBlock(line: string): boolean { function railsLineBlockDelta(line: string): number { const code = rubyLineWithoutStrings(line); - return rubyTokenCount(code, "do") - rubyTokenCount(code, "end"); + return rubyTokenCount(code, "do") - rubyTokenCount(code, "end") + rubyBraceDelta(code); } function rubyTokenCount(source: string, token: string): number { @@ -531,10 +531,28 @@ function rubyTokenCount(source: string, token: string): number { 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; @@ -556,6 +574,32 @@ function rubyLineWithoutStrings(line: string): string { 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) { From 0ae06e2bc4ebcbc3f2322adcd50bd7a59367fb2c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 09:53:23 +0100 Subject: [PATCH 3/3] feat(mapper): map literal Rails routes --- CHANGELOG.md | 1 + docs/feature-mapping.md | 3 ++- src/mapper.test.ts | 8 ++++++++ src/mappers/ruby.ts | 12 +++++++++++- 4 files changed, 22 insertions(+), 2 deletions(-) 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 7b75bcf..0f9aea0 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -1659,6 +1659,10 @@ describe("mapFeatures", () => { 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'", @@ -1703,6 +1707,7 @@ describe("mapFeatures", () => { " match '/legacy', to: 'legacy#show', via: :get", " resources :posts", "end", + "get '/outside-after', to: 'outside#show'", ].join("\n"), ); await writeFixture( @@ -1791,6 +1796,9 @@ describe("mapFeatures", () => { 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({ diff --git a/src/mappers/ruby.ts b/src/mappers/ruby.ts index ca663f5..a9ce306 100644 --- a/src/mappers/ruby.ts +++ b/src/mappers/ruby.ts @@ -491,11 +491,20 @@ async function railsSeeds( function railsRouteDeclarations(source: string): RailsRoute[] { const routes: RailsRoute[] = []; const seen = new Set(); + 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); @@ -510,9 +519,10 @@ function railsRouteDeclarations(source: string): RailsRoute[] { continue; } - if (!startsRailsRoutesDrawBlock(line) && blockDelta > 0) { + if (blockDelta > 0) { skippedBlockDepth = blockDelta; } + drawBlockDepth = Math.max(0, drawBlockDepth + blockDelta); } return routes; }