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
5 changes: 3 additions & 2 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,8 +399,9 @@ func (e *Engine) linkCrossRepo() {
// 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
// "unmatched_reason" (one of crossrepo's Reason* constants: no_method | generic_path |
// method_mismatch | path_unknown) — 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).
Expand Down
30 changes: 22 additions & 8 deletions internal/engine/unmatched_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ func TestFlagUnmatchedRoutes_ClientSide(t *testing.T) {
t.Fatalf("New: %v", err)
}
eng.store.Add(
clientRouteFact("app", "/api/items/{id}", "GET"), // resolves to backend
clientRouteFact("app", "/api/unknown/{id}", "GET"), // no server -> no_match
clientRouteFact("app", "/api/items/{id}", "GET"), // resolves to backend
clientRouteFact("app", "/api/unknown/{id}", "GET"), // no server serves it -> path_unknown
clientRouteFact("app", "/api/orders/{id}", "POST"), // path served, wrong verb -> method_mismatch
clientRouteFact("app", "/health", "GET"), // sub-2-segment path -> generic_path
facts.Fact{Kind: facts.KindRoute, Name: "/api/items/{itemId}", Repo: "backend",
Props: map[string]any{"role": "server", "method": "GET"}},
facts.Fact{Kind: facts.KindRoute, Name: "/api/orders/{orderId}", Repo: "backend",
Props: map[string]any{"role": "server", "method": "GET"}},
)

eng.flagUnmatchedRoutes()
Expand All @@ -37,12 +41,22 @@ func TestFlagUnmatchedRoutes_ClientSide(t *testing.T) {
}
}

unknown := props["/api/unknown/{id}"]
if e, _ := unknown["unmatched_by_server"].(bool); !e {
t.Errorf("/api/unknown should be unmatched_by_server; got %+v", unknown)
}
if unknown["unmatched_reason"] != "path_unknown" {
t.Errorf("/api/unknown reason = %v, want path_unknown", unknown["unmatched_reason"])
// Each reason the resolver can emit for an unresolved client call, asserted by name
// so the value set stays pinned to the crossrepo.Reason* constants.
for _, tc := range []struct {
name, wantReason string
}{
{"/api/unknown/{id}", "path_unknown"},
{"/api/orders/{id}", "method_mismatch"},
{"/health", "generic_path"},
} {
got := props[tc.name]
if e, _ := got["unmatched_by_server"].(bool); !e {
t.Errorf("%s should be unmatched_by_server; got %+v", tc.name, got)
}
if got["unmatched_reason"] != tc.wantReason {
t.Errorf("%s reason = %v, want %s", tc.name, got["unmatched_reason"], tc.wantReason)
}
}

items := props["/api/items/{id}"]
Expand Down
27 changes: 20 additions & 7 deletions internal/linkers/crossrepo/crossrepo.go
Original file line number Diff line number Diff line change
Expand Up @@ -452,11 +452,24 @@ func UnmatchedServerRouteKeys(all []facts.Fact) map[string]bool {
return unmatched
}

// The Reason* constants are the exhaustive set of values written to a client route's
// "unmatched_reason" prop by UnmatchedClientRouteKeys (surfaced via
// query_facts(kind=route, prop=unmatched_reason)). They are the string source of
// truth: the doc comments on UnmatchedClientRouteKeys and Engine.flagUnmatchedRoutes
// name them rather than restating the literals, so the value set cannot be described
// in two files and silently drift. Changing a value here changes emitted facts.
const (
ReasonNoMethod = "no_method" // the call site carried no usable HTTP verb
ReasonGenericPath = "generic_path" // a sub-2-segment path the matcher deliberately skips
ReasonMethodMismatch = "method_mismatch" // a server route serves this path suffix, but not this verb
ReasonPathUnknown = "path_unknown" // no server route shares a >=2-segment suffix with this path
)

// UnmatchedClientRouteKeys returns the identity (see RouteIdentity) of every client
// route the cross-repo HTTP linker could not resolve to a loaded server route,
// mapped to a short reason: "no_method" (the call site carried no usable verb),
// "generic_path" (a sub-2-segment path the matcher deliberately skips), or
// "no_match" (no server route shares a >=2-segment suffix + method). It mirrors
// mapped to one of the Reason* constants: ReasonNoMethod, ReasonGenericPath,
// ReasonMethodMismatch (a server serves this path suffix, but not this verb), or
// ReasonPathUnknown (no server shares a >=2-segment suffix with this path). It mirrors
// linkHTTP's exact resolution steps, so the set is precisely the client calls that
// fell into the unresolved coverage count — the queryable counterpart to the
// aggregate edge_coverage numbers. External calls (hardcoded third-party hosts) are
Expand All @@ -478,12 +491,12 @@ func UnmatchedClientRouteKeys(all []facts.Fact) map[string]string {
id := RouteIdentity(f)
method := normalizeMethod(propString(f, "method"))
if method == "" {
unmatched[id] = "no_method"
unmatched[id] = ReasonNoMethod
continue
}
np := normalizePath(f.Name)
if isGenericPath(np) {
unmatched[id] = "generic_path"
unmatched[id] = ReasonGenericPath
continue
}
cp := canonicalLeadingSlash(np)
Expand All @@ -492,9 +505,9 @@ func UnmatchedClientRouteKeys(all []facts.Fact) map[string]string {
// Distinguish "a server serves this path but not this verb" from "no
// server serves this path at all", so the residual is self-triaging.
if clientPathHasServer(serverSuffixes, cp) {
unmatched[id] = "method_mismatch"
unmatched[id] = ReasonMethodMismatch
} else {
unmatched[id] = "path_unknown"
unmatched[id] = ReasonPathUnknown
}
}
}
Expand Down
21 changes: 21 additions & 0 deletions internal/linkers/crossrepo/crossrepo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1032,3 +1032,24 @@ func TestComputeLinks_ExternalClientStillMatchesLoadedServer(t *testing.T) {
ec["detected"], ec["resolved"], ec["external"], ec["unresolved"])
}
}

// TestUnmatchedReasonConstants pins the string values of the Reason* constants. They
// are written verbatim to a client route's "unmatched_reason" prop and are queried by
// agents (query_facts(kind=route, prop=unmatched_reason, prop_value=...)), so a rename
// that changed a value would silently break every such query and desync the doc
// comments that name them. GAP-LK-10: the value "no_match" the comments once claimed is
// deliberately absent — the resolver splits it into method_mismatch and path_unknown.
func TestUnmatchedReasonConstants(t *testing.T) {
for _, tc := range []struct {
got, want string
}{
{ReasonNoMethod, "no_method"},
{ReasonGenericPath, "generic_path"},
{ReasonMethodMismatch, "method_mismatch"},
{ReasonPathUnknown, "path_unknown"},
} {
if tc.got != tc.want {
t.Errorf("reason constant = %q, want %q", tc.got, tc.want)
}
}
}
Loading