Skip to content

Commit 34b024e

Browse files
authored
Expand cross-repo HTTP-client detection (TS options-object/openapi-fe… (#66)
* Expand cross-repo HTTP-client detection (TS options-object/openapi-fetch, Swift endpoint enums with prefix resolution, Rails draw() scope prefixes) and remove import/shared-symbol false positives * Resolve Swift request-wrapper/stored-method endpoints from call sites and fix Rails nested singular/plural resource route paths (cache v72)
1 parent 4e7aed8 commit 34b024e

11 files changed

Lines changed: 2059 additions & 47 deletions

File tree

internal/engine/cache.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,20 @@ import (
129129
// v68: the TypeScript extractor now sets abstract:true on `abstract class`
130130
// declarations (was previously indistinguishable from a concrete class), so
131131
// package-metrics abstractness for TS must re-extract to pick up the flag.
132-
const cacheVersion = "v68"
132+
// v69: HTTP-client detection expanded — TS options-object/openapi-fetch clients,
133+
// Swift endpoint-enum (APIEndpoint/TargetType) clients, Rails draw(:pkg) routes now
134+
// carry their /api/vN scope prefix, and Swift URLSession skips test/fixture sources.
135+
// v70: Swift endpoint extractor resolves the version prefix — repo-wide default
136+
// (protocol-extension urlPrefixComponent), single-value/switch-default overrides,
137+
// and version-constant interpolation — so prefix-less endpoints match backend routes.
138+
// v71: Swift endpoint extractor resolves stored-method endpoint structs (path/prefix
139+
// computed, `method` a stored property) by reading the HTTP verb from each
140+
// instantiation site's `method:` argument, emitting one client route per (path, verb).
141+
// v72: Swift extractor also resolves request-wrapper endpoints (path supplied at the
142+
// call site's `urlPathComponent:` arg, verb from `method:`/`httpMethod:` or a type
143+
// default); Ruby extractor fixes nested Rails resource paths — a singular `resource`
144+
// gets no `:id`, and children of a plural `resources` nest under `:<singular>_id`.
145+
const cacheVersion = "v72"
133146

134147
// extractorCache holds per-extractor facts keyed by a content hash of the files
135148
// the extractor depends on. It is loaded from disk at the start of a snapshot and

internal/extractors/rubyextractor/routes.go

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,59 @@ import (
1010
)
1111

1212
// extractAllRoutes finds and parses all Rails route files in the repository.
13+
//
14+
// Rails commonly splits routes across config/routes/<pkg>.rb files pulled in by
15+
// draw(:pkg) from config/routes.rb, often inside a scope('/api')/namespace(:vN)
16+
// block. Parsed standalone, a delegated file loses that prefix, so its routes read
17+
// as "/devices" instead of "/api/v2/devices" and no longer match a client call. To
18+
// avoid that, the top-level config/routes.rb is parsed first to learn each
19+
// delegation's prefix, then each delegated file is parsed seeded with it.
1320
func extractAllRoutes(repoPath string, files []string) []facts.Fact {
14-
var allFacts []facts.Fact
15-
1621
// Collect route files: config/routes.rb, config/routes/*.rb, packages/*/config/routes/*.rb
1722
var routeFiles []string
1823
for _, relFile := range files {
19-
if !isRubyFile(relFile) {
20-
continue
21-
}
22-
if isRouteFile(relFile) {
24+
if isRubyFile(relFile) && isRouteFile(relFile) {
2325
routeFiles = append(routeFiles, relFile)
2426
}
2527
}
2628

27-
for _, relFile := range routeFiles {
28-
absFile := filepath.Join(repoPath, relFile)
29-
src, err := os.ReadFile(absFile)
29+
readSrc := func(relFile string) ([]byte, bool) {
30+
src, err := os.ReadFile(filepath.Join(repoPath, relFile))
3031
if err != nil {
3132
log.Printf("[ruby-extractor] error reading route file %s: %v", relFile, err)
33+
return nil, false
34+
}
35+
return src, true
36+
}
37+
38+
mainFile := filepath.Join("config", "routes.rb")
39+
40+
// Pass 1: parse the top-level routes.rb, learning draw(:pkg) -> prefix. Each
41+
// draw(:pkg) loads config/routes/<pkg>.rb (Rails convention).
42+
var allFacts []facts.Fact
43+
drawPrefix := map[string]string{}
44+
for _, relFile := range routeFiles {
45+
if relFile != mainFile {
3246
continue
3347
}
34-
allFacts = append(allFacts, parseRouteFileAST(src, relFile)...)
48+
if src, ok := readSrc(relFile); ok {
49+
ff, draws := parseRouteFile(src, relFile, "")
50+
allFacts = append(allFacts, ff...)
51+
for pkg, prefix := range draws {
52+
drawPrefix[filepath.Join("config", "routes", pkg+".rb")] = prefix
53+
}
54+
}
55+
}
56+
57+
// Pass 2: parse the remaining route files, seeding any prefix learned in pass 1.
58+
for _, relFile := range routeFiles {
59+
if relFile == mainFile {
60+
continue
61+
}
62+
if src, ok := readSrc(relFile); ok {
63+
ff, _ := parseRouteFile(src, relFile, drawPrefix[relFile])
64+
allFacts = append(allFacts, ff...)
65+
}
3566
}
3667

3768
return allFacts
@@ -61,6 +92,10 @@ func isRouteFile(relFile string) bool {
6192
type routeScope struct {
6293
pathPrefix string
6394
module string
95+
// memberParam is the parent member path parameter (`:<singular>_id`) that nested
96+
// resources declared inside a *plural* `resources` block must nest under; empty for
97+
// namespace/scope/singular-resource scopes, which add no member id to their children.
98+
memberParam string
6499
}
65100

66101
// buildPrefix constructs the current URL prefix from the scope stack.
@@ -103,6 +138,28 @@ func restfulActions(only, except map[string]bool) []restAction {
103138
return all
104139
}
105140

141+
// restfulActionsSingular returns the REST actions for a singular `resource`
142+
// declaration. A singular resource has no index and no `:id` member segment — every
143+
// action acts on the single resource at its base path.
144+
func restfulActionsSingular(only, except map[string]bool) []restAction {
145+
all := []restAction{
146+
{name: "create", method: "POST", suffix: ""},
147+
{name: "new", method: "GET", suffix: "/new"},
148+
{name: "show", method: "GET", suffix: ""},
149+
{name: "update", method: "PATCH", suffix: ""},
150+
{name: "edit", method: "GET", suffix: "/edit"},
151+
{name: "destroy", method: "DELETE", suffix: ""},
152+
}
153+
154+
if len(only) > 0 {
155+
return filterActions(all, only, true)
156+
}
157+
if len(except) > 0 {
158+
return filterActions(all, except, false)
159+
}
160+
return all
161+
}
162+
106163
// filterActions returns actions filtered by an allow or deny list.
107164
func filterActions(all []restAction, names map[string]bool, isAllow bool) []restAction {
108165
var result []restAction

internal/extractors/rubyextractor/routes_ast.go

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,40 @@ import (
1313
// facts. Block boundaries come from the grammar (do_block) rather than counting
1414
// `do`/`end`, so nested namespaces/resources/scopes are tracked precisely.
1515
func parseRouteFileAST(src []byte, relFile string) []facts.Fact {
16+
ff, _ := parseRouteFile(src, relFile, "")
17+
return ff
18+
}
19+
20+
// parseRouteFile parses a Rails route file, seeding the scope stack with
21+
// initialPrefix (the URL prefix a parent routes.rb delegated this file under via
22+
// draw(:pkg)), and additionally returns the draw(:pkg) -> prefix map discovered in
23+
// this file, so the caller can inline each delegated file under its real scope.
24+
func parseRouteFile(src []byte, relFile, initialPrefix string) ([]facts.Fact, map[string]string) {
1625
parser := sitter.NewParser()
1726
defer parser.Close()
1827
if err := parser.SetLanguage(sitter.NewLanguage(ruby.Language())); err != nil {
19-
return nil
28+
return nil, nil
2029
}
2130
tree := parser.Parse(src, nil)
2231
defer tree.Close()
2332

24-
rw := &routeWalker{src: src, relFile: relFile, dir: filepath.Dir(relFile)}
25-
rw.walk(tree.RootNode(), nil)
26-
return rw.out
33+
var stack []routeScope
34+
if initialPrefix != "" {
35+
stack = []routeScope{{pathPrefix: initialPrefix}}
36+
}
37+
rw := &routeWalker{src: src, relFile: relFile, dir: filepath.Dir(relFile), draws: map[string]string{}}
38+
rw.walk(tree.RootNode(), stack)
39+
return rw.out, rw.draws
2740
}
2841

2942
type routeWalker struct {
3043
src []byte
3144
relFile string
3245
dir string
3346
out []facts.Fact
47+
// draws maps each draw(:pkg) delegation found in this file to the URL prefix it
48+
// is scoped under, so the caller can parse config/routes/<pkg>.rb with it.
49+
draws map[string]string
3450
}
3551

3652
// walk iterates the statements of a program / body_statement, dispatching each
@@ -103,8 +119,25 @@ func (rw *routeWalker) handleCall(call *sitter.Node, stack []routeScope) {
103119
}
104120
only := pairSymbols(args, "only", rw.src)
105121
except := pairSymbols(args, "except", rw.src)
106-
resourcePath := prefix + "/" + name
107-
for _, a := range restfulActions(only, except) {
122+
singular := method == "resource"
123+
124+
// A resource nested inside a *plural* `resources` block nests under the parent
125+
// member (`/widgets/:widget_id/...`); the parent supplies that param via the
126+
// enclosing scope's memberParam.
127+
parentMember := ""
128+
if len(stack) > 0 {
129+
if p := stack[len(stack)-1].memberParam; p != "" {
130+
parentMember = "/:" + p
131+
}
132+
}
133+
segment := parentMember + "/" + name
134+
resourcePath := prefix + segment
135+
136+
actions := restfulActions(only, except)
137+
if singular {
138+
actions = restfulActionsSingular(only, except)
139+
}
140+
for _, a := range actions {
108141
rw.emit(resourcePath+a.suffix, line(call), map[string]any{
109142
"method": a.method,
110143
"framework": "rails",
@@ -114,7 +147,12 @@ func (rw *routeWalker) handleCall(call *sitter.Node, stack []routeScope) {
114147
})
115148
}
116149
if body != nil {
117-
rw.walk(body, append(stack, routeScope{pathPrefix: "/" + name}))
150+
// A plural resource exposes a member id to its children; a singular one does not.
151+
childMember := ""
152+
if !singular {
153+
childMember = singularize(name) + "_id"
154+
}
155+
rw.walk(body, append(stack, routeScope{pathPrefix: segment, memberParam: childMember}))
118156
}
119157

120158
case "namespace":
@@ -156,6 +194,13 @@ func (rw *routeWalker) handleCall(call *sitter.Node, stack []routeScope) {
156194
return
157195
}
158196
if pkg := firstSymbolArg(args, rw.src); pkg != "" {
197+
// Record the delegation so the caller can parse config/routes/<pkg>.rb
198+
// seeded with this prefix, giving its routes their real /api/vN scope.
199+
if rw.draws != nil {
200+
rw.draws[pkg] = prefix
201+
}
202+
// A DRAW placeholder route is still emitted (it backs route helpers); the
203+
// linker treats method "DRAW" as inert, so it never matches or is flagged.
159204
rw.out = append(rw.out, facts.Fact{
160205
Kind: facts.KindRoute,
161206
Name: prefix + "/" + pkg,

internal/extractors/rubyextractor/ruby_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,148 @@ end
774774
}
775775
}
776776

777+
// routeMethods indexes route facts as name -> set of HTTP methods (a singular
778+
// resource emits several verbs on one path, so a plain name->fact map would drop them).
779+
func routeMethods(result []facts.Fact) map[string]map[string]bool {
780+
out := map[string]map[string]bool{}
781+
for _, f := range result {
782+
if f.Kind != facts.KindRoute {
783+
continue
784+
}
785+
if out[f.Name] == nil {
786+
out[f.Name] = map[string]bool{}
787+
}
788+
if m, _ := f.Props["method"].(string); m != "" {
789+
out[f.Name][m] = true
790+
}
791+
}
792+
return out
793+
}
794+
795+
// TestRoutes_NestedSingularResource: a singular `resource` nested in a plural
796+
// `resources` nests under the parent member (`/:<singular>_id`) and has no id of its
797+
// own — show/create/destroy all map to the base path.
798+
func TestRoutes_NestedSingularResource(t *testing.T) {
799+
src := `Rails.application.routes.draw do
800+
resources :widgets, only: [:show] do
801+
resource :follow, only: [:show, :create, :destroy]
802+
end
803+
end
804+
`
805+
routes := routeMethods(parseRouteFileAST([]byte(src), "config/routes.rb"))
806+
807+
if _, ok := routes["/widgets/:id"]; !ok {
808+
t.Errorf("missing parent route /widgets/:id; got %v", routes)
809+
}
810+
follow := routes["/widgets/:widget_id/follow"]
811+
for _, m := range []string{"GET", "POST", "DELETE"} {
812+
if !follow[m] {
813+
t.Errorf("follow: missing %s among %v (route /widgets/:widget_id/follow)", m, follow)
814+
}
815+
}
816+
// The old buggy shapes must NOT appear.
817+
for _, absent := range []string{"/widgets/follow", "/widgets/follow/:id", "/widgets/:widget_id/follow/:id"} {
818+
if _, ok := routes[absent]; ok {
819+
t.Errorf("route %q should not be produced (nested singular resource)", absent)
820+
}
821+
}
822+
}
823+
824+
// TestRoutes_NestedPluralResources: a plural `resources` nested in a plural
825+
// `resources` nests under the parent member id.
826+
func TestRoutes_NestedPluralResources(t *testing.T) {
827+
src := `Rails.application.routes.draw do
828+
resources :widgets do
829+
resources :items, only: [:index, :show]
830+
end
831+
end
832+
`
833+
routes := routeMethods(parseRouteFileAST([]byte(src), "config/routes.rb"))
834+
for _, want := range []string{"/widgets/:widget_id/items", "/widgets/:widget_id/items/:id"} {
835+
if _, ok := routes[want]; !ok {
836+
t.Errorf("missing route %q; got %v", want, routes)
837+
}
838+
}
839+
if _, ok := routes["/widgets/items"]; ok {
840+
t.Errorf("nested plural resources must nest under the parent member id")
841+
}
842+
}
843+
844+
// TestRoutes_TopLevelSingularResource: a top-level singular `resource` has no id.
845+
func TestRoutes_TopLevelSingularResource(t *testing.T) {
846+
src := `Rails.application.routes.draw do
847+
resource :session, only: [:show, :create, :destroy]
848+
end
849+
`
850+
routes := routeMethods(parseRouteFileAST([]byte(src), "config/routes.rb"))
851+
session := routes["/session"]
852+
for _, m := range []string{"GET", "POST", "DELETE"} {
853+
if !session[m] {
854+
t.Errorf("session: missing %s among %v", m, session)
855+
}
856+
}
857+
if _, ok := routes["/session/:id"]; ok {
858+
t.Errorf("singular resource must not have an :id member path")
859+
}
860+
}
861+
862+
// TestRoutes_DrawPrefixSeeding verifies that (1) a top-level routes.rb reports the
863+
// prefix each draw(:pkg) is scoped under, and (2) parsing a delegated file seeded
864+
// with that prefix yields fully-qualified routes that a client call can match.
865+
func TestRoutes_DrawPrefixSeeding(t *testing.T) {
866+
main := `Rails.application.routes.draw do
867+
scope '/api', defaults: { format: 'json' } do
868+
namespace(:core) do
869+
namespace(:v3) do
870+
draw(:api_core_v3_routes)
871+
end
872+
end
873+
namespace(:v2) do
874+
draw(:api_v2_routes)
875+
end
876+
end
877+
draw(:admin_routes)
878+
end
879+
`
880+
_, draws := parseRouteFile([]byte(main), "config/routes.rb", "")
881+
if draws["api_core_v3_routes"] != "/api/core/v3" {
882+
t.Errorf("api_core_v3_routes prefix = %q, want /api/core/v3", draws["api_core_v3_routes"])
883+
}
884+
if draws["api_v2_routes"] != "/api/v2" {
885+
t.Errorf("api_v2_routes prefix = %q, want /api/v2", draws["api_v2_routes"])
886+
}
887+
if draws["admin_routes"] != "" {
888+
t.Errorf("admin_routes prefix = %q, want empty", draws["admin_routes"])
889+
}
890+
891+
// A delegated file parsed with the learned prefix produces qualified routes,
892+
// including single-segment collections lifted above the 2-segment match floor.
893+
sub := `resources :devices, only: [:destroy]
894+
resources :interactions, only: [:index]
895+
namespace :event do
896+
resources :posts, only: [:create]
897+
end
898+
`
899+
ff, _ := parseRouteFile([]byte(sub), "config/routes/api_v2_routes.rb", draws["api_v2_routes"])
900+
names := map[string]bool{}
901+
for _, f := range ff {
902+
names[f.Name] = true
903+
}
904+
for _, want := range []string{"/api/v2/devices/:id", "/api/v2/interactions", "/api/v2/event/posts"} {
905+
if !names[want] {
906+
t.Errorf("missing seeded route %q; got %v", want, keys2(names))
907+
}
908+
}
909+
}
910+
911+
func keys2(m map[string]bool) []string {
912+
out := make([]string, 0, len(m))
913+
for k := range m {
914+
out = append(out, k)
915+
}
916+
return out
917+
}
918+
777919
func TestRoutes_VerbWithHandler(t *testing.T) {
778920
src := `Rails.application.routes.draw do
779921
root to: "home#index"

0 commit comments

Comments
 (0)