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
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25.1"
go-version-file: go.mod
cache: true

- name: Run tests
Expand Down Expand Up @@ -51,7 +51,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25.1"
go-version-file: go.mod
cache: true

- name: Compute version
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/enola-labs/enola

go 1.25.11
go 1.25.12

require (
github.com/modelcontextprotocol/go-sdk v1.4.1
Expand Down
6 changes: 6 additions & 0 deletions internal/cachecov/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ var versionCoverage = map[int][]string{
86: {"TestAST_DataClassAndEnumProps"}, // Python data_class broadened to RootModel/*BaseModel subclasses (StrictBaseModel)
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
88: {"TestPyGRPC_ClientStubCall_EmitsRoute", "TestPyGRPC_StubRebinding_PositionalBinding", "TestPyGRPC_DynamicStubClass_NoRoute", "TestPyGRPC_NoStubImport_NoRoute"}, // Python gRPC client-role routes from stub.Method() call sites
89: {"TestMethodNear_BeforeAndEnumForms", "TestExtractEndpointFacts_ExternalHost"}, // Swift widened method inference + external-host tagging
90: {"TestRetrofit_AbsoluteURLExternal", "TestRoutes_MatchViaVerbs", "TestRoutes_ScopePathKeyword"}, // Kotlin Retrofit external tagging + Ruby match via:/scope path:
91: {"TestRoutes_ResourcesUpdatePutAndPatch"}, // Ruby resources/resource update emits PATCH + PUT
92: {"TestRoutes_SymbolPathArg", "TestRoutes_ScopeBareSymbolPrefix", "TestRoutes_ResourcePathOverride"}, // Ruby symbol path args + scope :symbol + resource path: override
93: {"TestSwitchReturns_MultiLineCaseLabels", "TestExtractEndpointFacts_MultiLineMethodCase"}, // Swift multi-line case-label method parsing
94: {"TestExtractEndpointFacts_ConstantMethod"}, // Swift single-value (constant) method property
}

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

// extractorCache holds per-extractor facts keyed by a content hash of the files
// the extractor depends on. It is loaded from disk at the start of a snapshot and
Expand Down
55 changes: 55 additions & 0 deletions internal/engine/coverage_summary_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package engine

// coverageSummary rolls up per-service edge_coverage into the snapshot-level
// CoverageSummary. These tests pin that external call sites are surfaced separately
// and excluded from the internal blind-spot count (unresolved) and gap tally.

import (
"testing"

"github.com/enola-labs/enola/internal/facts"
)

func svcCoverage(name string, resolved, unresolved, external int) facts.Fact {
return facts.Fact{
Kind: facts.KindService,
Name: name,
Repo: name,
Props: map[string]any{
"synthetic": "crossrepo",
"edge_coverage": []map[string]any{{
"edge_type": "http_client",
"detected": resolved + unresolved + external,
"resolved": resolved,
"unresolved": unresolved,
"external": external,
}},
},
}
}

func TestCoverageSummary_ExternalBucket(t *testing.T) {
st := facts.NewStore()
st.Add(
svcCoverage("a", 5, 2, 0), // 2 internal unresolved -> a coverage gap
svcCoverage("b", 4, 0, 3), // only external -> NOT a gap
svcCoverage("c", 1, 1, 4), // both -> gap, external counted separately
)

sum := coverageSummary(st)
if sum == nil {
t.Fatal("expected a CoverageSummary")
}
if sum.ServicesTotal != 3 {
t.Errorf("ServicesTotal = %d, want 3", sum.ServicesTotal)
}
if sum.CoverageGaps != 2 {
t.Errorf("CoverageGaps = %d, want 2 (external-only service is not a gap)", sum.CoverageGaps)
}
if sum.UnresolvedEdges != 3 {
t.Errorf("UnresolvedEdges = %d, want 3 (internal only: 2+1)", sum.UnresolvedEdges)
}
if sum.ExternalEdges != 7 {
t.Errorf("ExternalEdges = %d, want 7 (3+4)", sum.ExternalEdges)
}
}
41 changes: 26 additions & 15 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,40 +393,51 @@ func (e *Engine) linkCrossRepo() {
log.Printf("[engine] cross-repo links: %d service nodes, %d dependency edges", services, edges)
}

// flagUnmatchedRoutes marks each server route fact that no loaded client route
// resolves to with an "unmatched_by_clients" prop, and clears the prop on every
// other route, so the flag is recomputed idempotently on each (re-)link. The
// signal is only meaningful with 2+ repos loaded; for a single-repo snapshot the
// key set is empty and this pass simply clears any stale flags. Routes carrying
// the prop are the candidates the unused-routes explainer summarizes and that
// query_facts(kind=route, prop=unmatched_by_clients, prop_value=true) returns.
// flagUnmatchedRoutes marks each route fact with its cross-repo resolution verdict,
// recomputed idempotently on each (re-)link: a server route no loaded client calls
// gets "unmatched_by_clients" (the unused-routes candidates); a client call site
// that resolves to no loaded server route gets "unmatched_by_server" plus an
// "unmatched_reason" (no_method | generic_path | no_match) — the queryable
// counterpart to the aggregate coverage counts. Both signals are only meaningful
// with 2+ repos loaded; for a single-repo snapshot the key sets are empty and this
// pass simply clears any stale flags. Surfaced via
// query_facts(kind=route, prop=unmatched_by_clients|unmatched_by_server).
func (e *Engine) flagUnmatchedRoutes() {
keys := crossrepo.UnmatchedServerRouteKeys(e.store.All())
flagged := 0
serverKeys := crossrepo.UnmatchedServerRouteKeys(e.store.All())
clientKeys := crossrepo.UnmatchedClientRouteKeys(e.store.All())
flaggedServer, flaggedClient := 0, 0
e.store.UpdateWhere(func(f *facts.Fact) {
if f.Kind != facts.KindRoute {
return
}
// A client-role route is a call site, never a served endpoint; never flag
// it, even if it shares an identity with an unused server route.
// A client-role route is a call site, never a served endpoint: it carries the
// reverse (unmatched_by_server) verdict, never unmatched_by_clients.
if f.Props != nil && f.Props["role"] == "client" {
delete(f.Props, "unmatched_by_clients")
if reason, ok := clientKeys[crossrepo.RouteIdentity(*f)]; ok {
f.Props["unmatched_by_server"] = true
f.Props["unmatched_reason"] = reason
flaggedClient++
} else {
delete(f.Props, "unmatched_by_server")
delete(f.Props, "unmatched_reason")
}
return
}
if keys[crossrepo.RouteIdentity(*f)] {
if serverKeys[crossrepo.RouteIdentity(*f)] {
if f.Props == nil {
f.Props = map[string]any{}
}
f.Props["unmatched_by_clients"] = true
flagged++
flaggedServer++
return
}
if f.Props != nil {
delete(f.Props, "unmatched_by_clients")
}
})
if flagged > 0 {
log.Printf("[engine] flagged %d server route(s) unused by loaded clients", flagged)
if flaggedServer > 0 || flaggedClient > 0 {
log.Printf("[engine] flagged %d server route(s) unused by clients, %d client call(s) unresolved to a server", flaggedServer, flaggedClient)
}
}

Expand Down
14 changes: 8 additions & 6 deletions internal/engine/receipt.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,21 @@ func coverageSummary(store *facts.Store) *facts.CoverageSummary {
}
sum := &facts.CoverageSummary{ServicesTotal: len(services)}
for _, svc := range services {
unresolved := readUnresolved(svc)
unresolved := readCoverageField(svc, "unresolved")
if unresolved > 0 {
sum.CoverageGaps++
sum.UnresolvedEdges += unresolved
}
sum.ExternalEdges += readCoverageField(svc, "external")
}
return sum
}

// readUnresolved sums the unresolved outbound edge count across a service node's
// edge_coverage entries, tolerating both the in-memory shape and the float64
// shape that survives a facts.jsonl JSON round-trip (mirrors coverage.readCoverage).
func readUnresolved(svc facts.Fact) int {
// readCoverageField sums one numeric field (e.g. "unresolved" or "external") across
// a service node's edge_coverage entries, tolerating both the in-memory shape and
// the float64 shape that survives a facts.jsonl JSON round-trip (mirrors
// coverage.readCoverage).
func readCoverageField(svc facts.Fact, field string) int {
if svc.Props == nil {
return 0
}
Expand All @@ -143,7 +145,7 @@ func readUnresolved(svc facts.Fact) int {
}
total := 0
for _, m := range raw {
switch n := m["unresolved"].(type) {
switch n := m[field].(type) {
case int:
total += n
case float64:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
{"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":"."}]}
{"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":"."}]}
{"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"}]}
{"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"}]}
{"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"}]}
{"kind":"service","name":"server","repo":"server","props":{"synthetic":"crossrepo"}}
{"kind":"symbol","name":"..UserRepo","file":"client/repo.go","line":11,"repo":"client","props":{"exported":true,"language":"go","symbol_kind":"struct"},"relations":[{"kind":"declares","target":"."}]}
{"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":"."}]}
Expand Down
4 changes: 2 additions & 2 deletions internal/engine/testdata/golden/multirepo.facts.jsonl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{"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"}]}
{"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"}]}
{"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"}]}
{"kind":"service","name":"repoA","repo":"repoA","props":{"synthetic":"crossrepo"}}
{"kind":"service","name":"repoB","repo":"repoB","props":{"edge_coverage":[{"detected":1,"edge_type":"http_client","resolved":0,"unresolved":1}],"synthetic":"crossrepo"}}
{"kind":"service","name":"repoB","repo":"repoB","props":{"edge_coverage":[{"detected":1,"edge_type":"http_client","external":0,"resolved":0,"unresolved":1}],"synthetic":"crossrepo"}}
2 changes: 1 addition & 1 deletion internal/engine/testdata/golden/php_multirepo.facts.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
{"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"}]}
{"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"}]}
{"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"}]}
{"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"}]}
{"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"}]}
{"kind":"service","name":"provider","repo":"provider","props":{"synthetic":"crossrepo"}}
{"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"}]}
{"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"}]}
Expand Down
Loading
Loading