Skip to content

Commit be76612

Browse files
authored
Feat/coverage external bucket and swift methods (#77)
* feat(coverage): bucket external calls + widen Swift method inference Cross-repo coverage counted every unresolved client call as an internal blind spot, conflating genuine gaps with calls to hardcoded third-party hosts. Split those out and improve Swift verb detection so fewer internal calls are missed. - Swift URLSession method inference: scan a symmetric window (the verb is often set before the path is appended) and recognize enum / .rawValue / leading-dot `.method = .x` forms, validated against the verb set; GET only as a last resort. - Swift external detection: tag a client route whose base URL is a hardcoded absolute host with external=true + host (internal/extractors/swiftextractor). - Linker: httpCoverage gains an `external` tally; external client calls are bucketed out of unresolved and produce no cross-repo edge (internal/linkers/crossrepo). - Metric: CoverageSummary.ExternalEdges; unresolved_edges is now internal-only; surfaced in the coverage explainer, coverage_report, and the global receipt. - Bump extractor cacheVersion v88 -> v89 (Swift facts change) and register the cachecov guard entry for v89. Totals reconcile per service: detected = resolved + unresolved + external. * feat(coverage): make unresolved client calls queryable + safe extractor wins Aggregate coverage counts couldn't tell which client calls were unresolved, so every fix was guesswork. Add a per-call verdict and land the no-risk extractor gaps, so the residual can be triaged from data. - crossrepo: UnmatchedClientRouteKeys mirrors linkHTTP's resolution exactly; shared indexServerRoutes keeps the verdict in lockstep. - engine: flagUnmatchedRoutes tags client call sites unmatched_by_server + unmatched_reason (no_method | generic_path | no_match), recomputed each link. - kotlin/retrofit: absolute-URL annotations tagged external=true + host. - ruby routes: `match ... via:` verbs and `scope`/`namespace path:` prefixes. - Bump extractor cacheVersion v89 -> v90 and register the cachecov guard entry. * feat(coverage): resolve Rails PUT updates + split no_match reason Rails routes both PATCH and PUT to a resources `update` action, but the Ruby extractor modeled only PATCH — so mobile clients calling PUT for updates had no server route to match, despite the endpoint being served. - ruby routes: resources/resource update emits both PATCH and PUT (restfulActions + restfulActionsSingular); only:/except: keep them together. - crossrepo: split the client-side no_match verdict into method_mismatch (a server route shares the path suffix but not the verb) vs path_unknown (no server serves the path), via a method-agnostic suffix index — so the residual triages itself. - Bump extractor cacheVersion v90 -> v91 and register the cachecov guard entry. Measured on a 3-repo graph: unresolved client calls dropped 97 -> 59 (38 PUT updates recovered); the remainder splits 51 path_unknown / 7 method_mismatch / 1 generic_path. * feat(coverage): extract Rails symbol-path, scope-symbol, and resource path: routes The Ruby route extractor missed three common Rails DSL forms, so real backend endpoints weren't modeled and mobile clients calling them couldn't resolve. - get/post/match now accept a symbol path arg (`get :cities_by_zip`) via a new positional-only path helper that also avoids mistaking a `to:` handler string for the path. - `scope :users` (bare positional symbol) applies the path prefix, like `scope path: 'users'`. - `resource(s) ..., path: 'x'` overrides the URL segment (the resource name still drives props and the nested member param). - Bump extractor cacheVersion v91 -> v92 and register the cachecov guard entry. * feat(coverage): read Swift endpoint methods that defaulted to GET The Swift endpoint extractor derived the HTTP method only from a `method` switch parsed one line at a time, so two common shapes silently fell back to GET — making real POST/PUT/DELETE calls miss their backend route: - multi-line case-label lists: `switchReturns` read labels only from the line starting with `case `, dropping continuation-line labels (`case .a,\n .b: return .post`). Accumulate labels across lines until the `:`. - single-value method properties: `var method: HTTPMethod { return .post }` (no switch) yielded no cases; read its lone verb and apply it to every case. - Bump extractor cacheVersion v92 -> v94 (two distinct re-extraction triggers) and register the cachecov guard entries. * Fixing vuln check
1 parent 49e8afb commit be76612

25 files changed

Lines changed: 1018 additions & 109 deletions

.github/workflows/release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
- name: Set up Go
1818
uses: actions/setup-go@v5
1919
with:
20-
go-version: "1.25.1"
20+
go-version-file: go.mod
2121
cache: true
2222

2323
- name: Run tests
@@ -51,7 +51,7 @@ jobs:
5151
- name: Set up Go
5252
uses: actions/setup-go@v5
5353
with:
54-
go-version: "1.25.1"
54+
go-version-file: go.mod
5555
cache: true
5656

5757
- name: Compute version

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/enola-labs/enola
22

3-
go 1.25.11
3+
go 1.25.12
44

55
require (
66
github.com/modelcontextprotocol/go-sdk v1.4.1

internal/cachecov/coverage_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ var versionCoverage = map[int][]string{
120120
86: {"TestAST_DataClassAndEnumProps"}, // Python data_class broadened to RootModel/*BaseModel subclasses (StrictBaseModel)
121121
87: {"TestAST_ParamCall_NoEdge", "TestAST_LocalCallable_NoEdge", "TestAST_LoopVarCall_NoEdge", "TestAST_SameModuleCall_StillResolves"}, // Python resolveCall no longer fabricates same-module edges for params/locals/loop vars
122122
88: {"TestPyGRPC_ClientStubCall_EmitsRoute", "TestPyGRPC_StubRebinding_PositionalBinding", "TestPyGRPC_DynamicStubClass_NoRoute", "TestPyGRPC_NoStubImport_NoRoute"}, // Python gRPC client-role routes from stub.Method() call sites
123+
89: {"TestMethodNear_BeforeAndEnumForms", "TestExtractEndpointFacts_ExternalHost"}, // Swift widened method inference + external-host tagging
124+
90: {"TestRetrofit_AbsoluteURLExternal", "TestRoutes_MatchViaVerbs", "TestRoutes_ScopePathKeyword"}, // Kotlin Retrofit external tagging + Ruby match via:/scope path:
125+
91: {"TestRoutes_ResourcesUpdatePutAndPatch"}, // Ruby resources/resource update emits PATCH + PUT
126+
92: {"TestRoutes_SymbolPathArg", "TestRoutes_ScopeBareSymbolPrefix", "TestRoutes_ResourcePathOverride"}, // Ruby symbol path args + scope :symbol + resource path: override
127+
93: {"TestSwitchReturns_MultiLineCaseLabels", "TestExtractEndpointFacts_MultiLineMethodCase"}, // Swift multi-line case-label method parsing
128+
94: {"TestExtractEndpointFacts_ConstantMethod"}, // Swift single-value (constant) method property
123129
}
124130

125131
func TestCacheVersionCoverage(t *testing.T) {

internal/engine/cache.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,27 @@ import (
229229
// v88: Python extractor now emits gRPC client-role routes (source=python-grpc-client) for
230230
// stub.Method(...) call sites, detected from source. New facts, so cached Python snapshots must
231231
// re-extract to pick them up.
232-
const cacheVersion = "v88"
232+
// v89: Swift HTTP-client extractor widens method inference (symmetric scan window + enum/
233+
// .rawValue/Alamofire .method forms) and tags calls to hardcoded absolute hosts with
234+
// external=true + host. Changes route methods and props, so cached Swift snapshots must
235+
// re-extract.
236+
// v90: Kotlin Retrofit extractor tags absolute-URL annotations external=true + host; Ruby
237+
// route extractor adds `match ... via:` verbs and reads `scope`/`namespace path:` keyword
238+
// prefixes. New/changed route facts, so cached Kotlin and Ruby snapshots must re-extract.
239+
// v91: Ruby route extractor emits both PATCH and PUT for the resources/resource update
240+
// action (Rails routes both verbs to update), so a client calling PUT resolves. New route
241+
// facts, so cached Ruby snapshots must re-extract.
242+
// v92: Ruby route extractor handles symbol path args (`get :cities_by_zip`), a bare-symbol
243+
// `scope :users` path prefix, and the `resource(s) ..., path:` segment override. New/
244+
// corrected route paths, so cached Ruby snapshots must re-extract.
245+
// v93: Swift endpoint extractor's switchReturns now collects case labels that wrap across
246+
// multiple lines, so a `case .a,\n .b: return .post` maps every label (not just the first)
247+
// — correcting HTTP methods that previously defaulted to GET. Cached Swift snapshots must
248+
// re-extract.
249+
// v94: Swift endpoint extractor reads a single-value method property (`var method:
250+
// HTTPMethod { return .post }`, no switch) and applies its lone verb to every case, instead
251+
// of defaulting to GET. Cached Swift snapshots must re-extract.
252+
const cacheVersion = "v94"
233253

234254
// extractorCache holds per-extractor facts keyed by a content hash of the files
235255
// the extractor depends on. It is loaded from disk at the start of a snapshot and
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package engine
2+
3+
// coverageSummary rolls up per-service edge_coverage into the snapshot-level
4+
// CoverageSummary. These tests pin that external call sites are surfaced separately
5+
// and excluded from the internal blind-spot count (unresolved) and gap tally.
6+
7+
import (
8+
"testing"
9+
10+
"github.com/enola-labs/enola/internal/facts"
11+
)
12+
13+
func svcCoverage(name string, resolved, unresolved, external int) facts.Fact {
14+
return facts.Fact{
15+
Kind: facts.KindService,
16+
Name: name,
17+
Repo: name,
18+
Props: map[string]any{
19+
"synthetic": "crossrepo",
20+
"edge_coverage": []map[string]any{{
21+
"edge_type": "http_client",
22+
"detected": resolved + unresolved + external,
23+
"resolved": resolved,
24+
"unresolved": unresolved,
25+
"external": external,
26+
}},
27+
},
28+
}
29+
}
30+
31+
func TestCoverageSummary_ExternalBucket(t *testing.T) {
32+
st := facts.NewStore()
33+
st.Add(
34+
svcCoverage("a", 5, 2, 0), // 2 internal unresolved -> a coverage gap
35+
svcCoverage("b", 4, 0, 3), // only external -> NOT a gap
36+
svcCoverage("c", 1, 1, 4), // both -> gap, external counted separately
37+
)
38+
39+
sum := coverageSummary(st)
40+
if sum == nil {
41+
t.Fatal("expected a CoverageSummary")
42+
}
43+
if sum.ServicesTotal != 3 {
44+
t.Errorf("ServicesTotal = %d, want 3", sum.ServicesTotal)
45+
}
46+
if sum.CoverageGaps != 2 {
47+
t.Errorf("CoverageGaps = %d, want 2 (external-only service is not a gap)", sum.CoverageGaps)
48+
}
49+
if sum.UnresolvedEdges != 3 {
50+
t.Errorf("UnresolvedEdges = %d, want 3 (internal only: 2+1)", sum.UnresolvedEdges)
51+
}
52+
if sum.ExternalEdges != 7 {
53+
t.Errorf("ExternalEdges = %d, want 7 (3+4)", sum.ExternalEdges)
54+
}
55+
}

internal/engine/engine.go

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -393,40 +393,51 @@ func (e *Engine) linkCrossRepo() {
393393
log.Printf("[engine] cross-repo links: %d service nodes, %d dependency edges", services, edges)
394394
}
395395

396-
// flagUnmatchedRoutes marks each server route fact that no loaded client route
397-
// resolves to with an "unmatched_by_clients" prop, and clears the prop on every
398-
// other route, so the flag is recomputed idempotently on each (re-)link. The
399-
// signal is only meaningful with 2+ repos loaded; for a single-repo snapshot the
400-
// key set is empty and this pass simply clears any stale flags. Routes carrying
401-
// the prop are the candidates the unused-routes explainer summarizes and that
402-
// query_facts(kind=route, prop=unmatched_by_clients, prop_value=true) returns.
396+
// flagUnmatchedRoutes marks each route fact with its cross-repo resolution verdict,
397+
// recomputed idempotently on each (re-)link: a server route no loaded client calls
398+
// gets "unmatched_by_clients" (the unused-routes candidates); a client call site
399+
// that resolves to no loaded server route gets "unmatched_by_server" plus an
400+
// "unmatched_reason" (no_method | generic_path | no_match) — the queryable
401+
// counterpart to the aggregate coverage counts. Both signals are only meaningful
402+
// with 2+ repos loaded; for a single-repo snapshot the key sets are empty and this
403+
// pass simply clears any stale flags. Surfaced via
404+
// query_facts(kind=route, prop=unmatched_by_clients|unmatched_by_server).
403405
func (e *Engine) flagUnmatchedRoutes() {
404-
keys := crossrepo.UnmatchedServerRouteKeys(e.store.All())
405-
flagged := 0
406+
serverKeys := crossrepo.UnmatchedServerRouteKeys(e.store.All())
407+
clientKeys := crossrepo.UnmatchedClientRouteKeys(e.store.All())
408+
flaggedServer, flaggedClient := 0, 0
406409
e.store.UpdateWhere(func(f *facts.Fact) {
407410
if f.Kind != facts.KindRoute {
408411
return
409412
}
410-
// A client-role route is a call site, never a served endpoint; never flag
411-
// it, even if it shares an identity with an unused server route.
413+
// A client-role route is a call site, never a served endpoint: it carries the
414+
// reverse (unmatched_by_server) verdict, never unmatched_by_clients.
412415
if f.Props != nil && f.Props["role"] == "client" {
413416
delete(f.Props, "unmatched_by_clients")
417+
if reason, ok := clientKeys[crossrepo.RouteIdentity(*f)]; ok {
418+
f.Props["unmatched_by_server"] = true
419+
f.Props["unmatched_reason"] = reason
420+
flaggedClient++
421+
} else {
422+
delete(f.Props, "unmatched_by_server")
423+
delete(f.Props, "unmatched_reason")
424+
}
414425
return
415426
}
416-
if keys[crossrepo.RouteIdentity(*f)] {
427+
if serverKeys[crossrepo.RouteIdentity(*f)] {
417428
if f.Props == nil {
418429
f.Props = map[string]any{}
419430
}
420431
f.Props["unmatched_by_clients"] = true
421-
flagged++
432+
flaggedServer++
422433
return
423434
}
424435
if f.Props != nil {
425436
delete(f.Props, "unmatched_by_clients")
426437
}
427438
})
428-
if flagged > 0 {
429-
log.Printf("[engine] flagged %d server route(s) unused by loaded clients", flagged)
439+
if flaggedServer > 0 || flaggedClient > 0 {
440+
log.Printf("[engine] flagged %d server route(s) unused by clients, %d client call(s) unresolved to a server", flaggedServer, flaggedClient)
430441
}
431442
}
432443

internal/engine/receipt.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,19 +112,21 @@ func coverageSummary(store *facts.Store) *facts.CoverageSummary {
112112
}
113113
sum := &facts.CoverageSummary{ServicesTotal: len(services)}
114114
for _, svc := range services {
115-
unresolved := readUnresolved(svc)
115+
unresolved := readCoverageField(svc, "unresolved")
116116
if unresolved > 0 {
117117
sum.CoverageGaps++
118118
sum.UnresolvedEdges += unresolved
119119
}
120+
sum.ExternalEdges += readCoverageField(svc, "external")
120121
}
121122
return sum
122123
}
123124

124-
// readUnresolved sums the unresolved outbound edge count across a service node's
125-
// edge_coverage entries, tolerating both the in-memory shape and the float64
126-
// shape that survives a facts.jsonl JSON round-trip (mirrors coverage.readCoverage).
127-
func readUnresolved(svc facts.Fact) int {
125+
// readCoverageField sums one numeric field (e.g. "unresolved" or "external") across
126+
// a service node's edge_coverage entries, tolerating both the in-memory shape and
127+
// the float64 shape that survives a facts.jsonl JSON round-trip (mirrors
128+
// coverage.readCoverage).
129+
func readCoverageField(svc facts.Fact, field string) int {
128130
if svc.Props == nil {
129131
return 0
130132
}
@@ -143,7 +145,7 @@ func readUnresolved(svc facts.Fact) int {
143145
}
144146
total := 0
145147
for _, m := range raw {
146-
switch n := m["unresolved"].(type) {
148+
switch n := m[field].(type) {
147149
case int:
148150
total += n
149151
case float64:

internal/engine/testdata/golden/go_grpc_multirepo.facts.jsonl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
{"kind":"route","name":"/users.v1.UserService/GetUser","file":"client/pkgvar.go","line":15,"repo":"client","props":{"framework":"grpc","language":"go","method":"POST","role":"client","rpc_method":"GetUser","rpc_service":"users.v1.UserService","source":"go-grpc-client","type":"grpc"},"relations":[{"kind":"declares","target":"."}]}
1717
{"kind":"route","name":"/users.v1.UserService/GetUser","file":"client/repo.go","line":16,"repo":"client","props":{"framework":"grpc","language":"go","method":"POST","role":"client","rpc_method":"GetUser","rpc_service":"users.v1.UserService","source":"go-grpc-client","type":"grpc"},"relations":[{"kind":"declares","target":"."}]}
1818
{"kind":"route","name":"/users.v1.UserService/GetUser","file":"server/proto/users/v1/users.proto","line":11,"repo":"server","props":{"framework":"grpc","language":"grpc","method":"POST","role":"server","rpc_method":"GetUser","rpc_service":"users.v1.UserService","source":"grpc-proto","streaming":"none","type":"grpc"},"relations":[{"kind":"declares","target":"proto/users/v1"}]}
19-
{"kind":"service","name":"client","repo":"client","props":{"edge_coverage":[{"detected":3,"edge_type":"http_client","resolved":3,"unresolved":0}],"synthetic":"crossrepo"},"relations":[{"kind":"depends_on","target":"server"}]}
19+
{"kind":"service","name":"client","repo":"client","props":{"edge_coverage":[{"detected":3,"edge_type":"http_client","external":0,"resolved":3,"unresolved":0}],"synthetic":"crossrepo"},"relations":[{"kind":"depends_on","target":"server"}]}
2020
{"kind":"service","name":"server","repo":"server","props":{"synthetic":"crossrepo"}}
2121
{"kind":"symbol","name":"..UserRepo","file":"client/repo.go","line":11,"repo":"client","props":{"exported":true,"language":"go","symbol_kind":"struct"},"relations":[{"kind":"declares","target":"."}]}
2222
{"kind":"symbol","name":"..UserRepo.Fetch","file":"client/repo.go","line":15,"repo":"client","props":{"cyclomatic":1,"exported":true,"language":"go","receiver":"UserRepo","symbol_kind":"method"},"relations":[{"kind":"calls","target":"gen/users/v1.UserServiceClient.GetUser"},{"kind":"declares","target":"."}]}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{"kind":"route","name":"/widgets","file":"repoA/api/openapi/widgets.yaml","repo":"repoA","props":{"framework":"openapi","language":"openapi","method":"GET","operationId":"listWidgets","role":"server","source":"openapi","spec_file":"api/openapi/widgets.yaml","summary":"List widgets served by repoA","tags":["widgets"]},"relations":[{"kind":"declares","target":"api/openapi"}]}
2-
{"kind":"route","name":"/widgets","file":"repoB/api/openapi/client/widgets.yml","repo":"repoB","props":{"framework":"openapi","language":"openapi","method":"GET","operationId":"fetchWidgets","role":"client","source":"openapi","spec_file":"api/openapi/client/widgets.yml","summary":"repoB calls repoA's GET /widgets","tags":["widgets"]},"relations":[{"kind":"declares","target":"api/openapi/client"}]}
2+
{"kind":"route","name":"/widgets","file":"repoB/api/openapi/client/widgets.yml","repo":"repoB","props":{"framework":"openapi","language":"openapi","method":"GET","operationId":"fetchWidgets","role":"client","source":"openapi","spec_file":"api/openapi/client/widgets.yml","summary":"repoB calls repoA's GET /widgets","tags":["widgets"],"unmatched_by_server":true,"unmatched_reason":"generic_path"},"relations":[{"kind":"declares","target":"api/openapi/client"}]}
33
{"kind":"service","name":"repoA","repo":"repoA","props":{"synthetic":"crossrepo"}}
4-
{"kind":"service","name":"repoB","repo":"repoB","props":{"edge_coverage":[{"detected":1,"edge_type":"http_client","resolved":0,"unresolved":1}],"synthetic":"crossrepo"}}
4+
{"kind":"service","name":"repoB","repo":"repoB","props":{"edge_coverage":[{"detected":1,"edge_type":"http_client","external":0,"resolved":0,"unresolved":1}],"synthetic":"crossrepo"}}

internal/engine/testdata/golden/php_multirepo.facts.jsonl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
{"kind":"route","name":"/billing/invoices","file":"provider/routes/api.php","line":10,"repo":"provider","props":{"framework":"laravel","handler":"InvoiceController::store","language":"php","method":"POST","path":"/billing/invoices","role":"server"},"relations":[{"kind":"declares","target":"routes"}]}
1212
{"kind":"route","name":"/billing/invoices","file":"provider/routes/api.php","line":9,"repo":"provider","props":{"framework":"laravel","handler":"InvoiceController::index","language":"php","method":"GET","path":"/billing/invoices","role":"server"},"relations":[{"kind":"declares","target":"routes"}]}
1313
{"kind":"route","name":"/billing/invoices/{id}","file":"provider/routes/api.php","line":11,"repo":"provider","props":{"framework":"laravel","handler":"InvoiceController::show","language":"php","method":"GET","path":"/billing/invoices/{id}","role":"server","unmatched_by_clients":true},"relations":[{"kind":"declares","target":"routes"}]}
14-
{"kind":"service","name":"consumer","repo":"consumer","props":{"edge_coverage":[{"detected":2,"edge_type":"http_client","resolved":2,"unresolved":0}],"synthetic":"crossrepo"},"relations":[{"kind":"depends_on","target":"provider"}]}
14+
{"kind":"service","name":"consumer","repo":"consumer","props":{"edge_coverage":[{"detected":2,"edge_type":"http_client","external":0,"resolved":2,"unresolved":0}],"synthetic":"crossrepo"},"relations":[{"kind":"depends_on","target":"provider"}]}
1515
{"kind":"service","name":"provider","repo":"provider","props":{"synthetic":"crossrepo"}}
1616
{"kind":"symbol","name":"App\\Http\\Controllers\\InvoiceController","file":"provider/app/Http/Controllers/InvoiceController.php","line":5,"repo":"provider","props":{"exported":true,"language":"php","symbol_kind":"class"},"relations":[{"kind":"declares","target":"app/Http/Controllers"}]}
1717
{"kind":"symbol","name":"App\\Http\\Controllers\\InvoiceController::index","file":"provider/app/Http/Controllers/InvoiceController.php","line":7,"repo":"provider","props":{"cyclomatic":1,"exported":true,"language":"php","symbol_kind":"method","visibility":"public"},"relations":[{"kind":"declares","target":"app/Http/Controllers"}]}

0 commit comments

Comments
 (0)