Skip to content

Commit 3842a4a

Browse files
authored
feat(tsextractor): extract TypeScript server-side routes (#155)
TypeScript emitted server routes only for file-based routers (Next.js, Nuxt, SvelteKit) — every `role` it wrote was "client". A decorator- or call-routed backend contributed zero routes, so every client call against it stayed unresolved and the backend itself was classified `isolated`, i.e. a leaf. Two new passes: - @controller / @controller classes (NestJS, InversifyJS), both the string and object argument forms, gated on the controller decorator so a generic @get on an ordinary class mints nothing. - <recv>.<verb>('/path', handler) for Express/Fastify/Hono/Koa, ESM and CommonJS bindings. This is the same text as a client call, so the two are separated by receiver binding; an unknown receiver stays a client call, unchanged. Deliberately not emitted, because the declaration does not determine the path: NestJS `version:`, the env-derived global prefix, a sub-router whose mount lives in another file, and bare '*' catch-alls. Both passes are gated on facts.IsTestPath, so an e2e fixture cannot mint routes nothing calls. Also folds four copies of route-path composition (Axum, FastAPI, Spring, Symfony) into facts.JoinRoutePath. They had drifted on whether the result is "/"-rooted, which the linker's suffix matching depends on. cacheVersion v142, v143.
1 parent 6696fc6 commit 3842a4a

36 files changed

Lines changed: 1352 additions & 80 deletions

internal/cachecov/coverage_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ var versionCoverage = map[int][]string{
170170
136: {"TestExtractTestRefs_ResolvesAbsoluteImport", "TestExtractTestRefs_EmitsNoSymbolsModulesOrRoutes", "TestExtractTestRefs_FixtureRouterMountIsNotARoute", "TestExtractTestRefs_DropsExternalTargets", "TestExtractTestRefs_NeedsProductionFileSet", "TestGolden"},
171171
137: {"TestResolveCallTargets_PackageReexport_ResolvesToDefiningModule", "TestResolveCallTargets_PackageReexport_NameDiffersFromModule", "TestResolveCallTargets_PackageReexport_AmbiguousStaysDotted", "TestResolveCallTargets_PackageReexport_ExternalSourceIgnored", "TestResolveCallTargets_ExactModuleWinsOverReexport", "TestGolden"},
172172
140: {"TestImportableRoots_NonPackageDirStartsNewRoot", "TestResolveCallTargets_SiblingImportInNonPackageDir", "TestAST_DecoratorArgumentFunctionIsReferenced", "TestAST_DecoratorArgumentKeepsNestedCalls", "TestGolden"},
173-
141: {"TestExtractHTTPClientFacts_NestedGenericTypeArg", "TestExtractHTTPClientFacts_LowercaseVerbCalls", "TestExtractHTTPClientFacts_LowercaseInterpolatedBaseNotMatched", "TestExtractHTTPClientFacts_TestFileCallsAreNotClientRoutes", "TestExtractHTTPClientFacts_VerbNamedCalls", "TestGolden"}, // a nested type argument (fetch<ApiResponse<Foo>>) no longer defeats client-call detection, and lowercase verb calls (axios.get('/x')) are detected when the argument is a "/"-rooted literal — the condition that keeps map.get('key')/cache.delete(id) out // a directory without __init__.py starts a new source root, so bare-name sibling imports inside it resolve (while a like-named third-party dir whose parent IS a package stays excluded); and a function passed to a decorator as a value is recorded as a reference instead of reading as dead
173+
141: {"TestExtractHTTPClientFacts_NestedGenericTypeArg", "TestExtractHTTPClientFacts_LowercaseVerbCalls", "TestExtractHTTPClientFacts_LowercaseInterpolatedBaseNotMatched", "TestExtractHTTPClientFacts_TestFileCallsAreNotClientRoutes", "TestExtractHTTPClientFacts_VerbNamedCalls", "TestGolden"},
174+
142: {"TestDecoratorRoutes_NestObjectForm", "TestDecoratorRoutes_NestStringFormAndBareVerb", "TestDecoratorRoutes_Inversify", "TestDecoratorRoutes_RequiresControllerDecorator", "TestDecoratorRoutes_VocabulariesDoNotMix", "TestDecoratorRoutes_DecoratorsDoNotCarryAcrossMembers", "TestDecoratorRoutes_TestFileEmitsNothing", "TestDecoratorRoutes_CommentBetweenDecoratorAndMethod", "TestGolden"}, // TypeScript's first server-side route DSL: @Controller/@controller classes emit one server route per verb-decorated method, both argument forms, gated on the controller decorator and on IsTestPath // a nested type argument (fetch<ApiResponse<Foo>>) no longer defeats client-call detection, and lowercase verb calls (axios.get('/x')) are detected when the argument is a "/"-rooted literal — the condition that keeps map.get('key')/cache.delete(id) out // a directory without __init__.py starts a new source root, so bare-name sibling imports inside it resolve (while a like-named third-party dir whose parent IS a package stays excluded); and a function passed to a decorator as a value is recorded as a reference instead of reading as dead
175+
143: {"TestServerRoutes_ExpressApp", "TestServerRoutes_UnmountedRouterEmitsNothing", "TestServerRoutes_SameFileMountComposes", "TestServerRoutes_DoNotStealClientCalls", "TestServerRoutes_NoDoubleEmission", "TestServerRoutes_OtherFrameworks", "TestServerRoutes_TestFileEmitsNothing", "TestGolden"}, // call-registered server routes (Express/Fastify/Hono/Koa), separated from v141 client calls by receiver binding; an unmounted sub-router and a bare catch-all emit nothing
174176
139: {"TestLooksGenerated", "TestApplyDecoratorProps_FrameworkRegistered", "TestApplyDecoratorProps_ModalNeedsImportGuard", "TestGolden"}, // facts from files carrying a codegen banner gain generated=true (language-agnostic, matched against the file head), and Python decorator-registered handlers (FastAPI exception_handler/middleware/on_event/websocket, Modal local_entrypoint and — gated on a modal import — function/cls) gain framework_registered=true, so the dead-code detector can drop findings it can never act on
175177
138: {"TestResolveImports_NestedLookalikeDoesNotCaptureThirdParty", "TestResolveImports_MultiSourceRootSurvivesPackageBoundaryRule", "TestResolveImports_SubpackageNotReachableByBareName", "TestBuildSuffixIndex_NoPackageDirsStaysPermissive", "TestResolveCallTargets_NestedLookalikeThirdPartyDropped", "TestResolveCallTargets_ClassQualifiedChainResolves", "TestResolveCallTargets_ClassQualifiedUnconfirmedStaysDotted", "TestResolveCallTargets_ClassQualifiedThroughReexport", "TestResolveCallTargets_SingleSegmentNeedsNoConfirmation", "TestGolden"}, // Python: (a) a directory is only a top-level package if its parent is not one, so an internal dir sharing a third-party name no longer captures its imports; (b) call-target resolution walks the module/symbol split leftwards with an exact module lookup, so a class-qualified chain binds to module.Class.method instead of being silently rewritten to module.method — multi-segment symbols must be confirmed against real symbol names // Python: a call target imported through a package __init__.py re-export resolves to the module that defines the symbol instead of dangling as a dotted string that matches no node; exact module resolution still wins and an ambiguous re-export stays dotted rather than binding arbitrarily // Python implements plugin.TestRefExtractor (which now also receives the production file list, needed to resolve dotted absolute-import targets); a symbol exercised only by a pytest file stops reading as dead, while the pass emits ONLY KindTestRef facts so a fixture's include_router cannot re-enter the production route graph // "**/testdata/**" joins the default ignore globs, and the two extractors that walk the repo themselves — OpenAPI (Extract) and gRPC (Detect) — repeat it in skipDir, which the globs cannot reach; Go fixture repos are miniature codebases whose routes and client call sites were being attributed to the host repo's service, manufacturing a cross-repo coverage gap
176178
}

internal/engine/cache.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,50 @@ import (
799799
// dependency edge out of test traffic — the paths matched a real server because
800800
// they are the routes under test. Same principle as v-era GAP-XL-15, which keeps
801801
// test_ref facts out of the coupling graph.
802-
const cacheVersion = "v141"
802+
// v142: TypeScript gains its first SERVER-side route DSL. Until now every `role` the
803+
// extractor wrote was "client" — server routes existed only for file-based routers
804+
// (Next.js, Nuxt, SvelteKit) — so a decorator-routed backend contributed zero routes,
805+
// every client call against it fell into the unresolved residual, and the backend was
806+
// classified `isolated`, i.e. a leaf.
807+
//
808+
// A class carrying @Controller (NestJS) or @controller (InversifyJS) now emits one
809+
// server route per verb-decorated method, composing the class base path with the
810+
// method sub-path through facts.JoinRoutePath. Both argument forms are read —
811+
// @Controller("/users") and @Controller({path: "/users"}), the latter being the form
812+
// real NestJS code overwhelmingly uses. Emission is gated on the controller
813+
// decorator, so a generic @Get on an ordinary class mints nothing, and the two
814+
// frameworks' verb vocabularies are kept separate so a class cannot mix them.
815+
//
816+
// Two things are deliberately NOT composed into the path, because the decorator does
817+
// not determine them: a `version:` property (NestJS versioning may be header- or
818+
// media-type-based) and the application's global prefix (routinely read from the
819+
// environment). The linker's >=2-segment suffix match resolves the difference.
820+
//
821+
// Gated on facts.IsTestPath like v141's client side: an e2e fixture's controller
822+
// would otherwise mint server routes no production client calls, which is a false
823+
// unused-route finding rather than a discovery.
824+
// v143: the other half of TypeScript's server side — routes registered by CALL
825+
// rather than by decorator. `<recv>.<verb>('/path', handler)` is the shape Express,
826+
// Fastify, Hono and Koa/Oak all share, so one pass covers the family, and both the
827+
// ESM and CommonJS binding forms are read (`const app = require('express')()` is as
828+
// common as the import form, and matching only the latter found zero routes on the
829+
// one real Express server available to measure against).
830+
//
831+
// The shape is also v141's CLIENT shape — axios.get('/x') and router.get('/x') are
832+
// the same text — so the two passes are separated by RECEIVER BINDING, resolved per
833+
// file. A receiver bound to an app or router registers routes; anything else stays a
834+
// client call, unchanged. The client pass skips known server receivers so a single
835+
// call site cannot be emitted twice, once in each direction; measured on the corpus,
836+
// that also corrected two registrations v141 had been reporting as outbound calls.
837+
//
838+
// A sub-router with no visible mount emits NOTHING. Its paths are fragments —
839+
// router.post('/login') in a module mounted at '/webhooks' elsewhere serves
840+
// '/webhooks/login' — so emitting '/login' would be a wrong fact, and a wrong path can
841+
// false-match another repo's route, which is worse than silence. Mounts declared in
842+
// the same file are composed; cross-file mount resolution needs a repo-wide pass and
843+
// is deliberately not attempted. Bare catch-alls (app.get('*')) are skipped for the
844+
// same reason: a SPA fallback is not an endpoint and would match any client path.
845+
const cacheVersion = "v143"
803846

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

internal/engine/golden_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,26 @@ var fixtures = []fixture{
9898
// Two different-language repos sharing only nested type names. The linker must
9999
// draw no shared_symbols edge between them; see GAP-LK-03.
100100
{name: "kotlin_swift_multirepo", subRepos: []string{"android", "ios"}},
101+
// A decorator-routed TypeScript backend plus an SDK that calls it. Pins v142 end
102+
// to end, which the unit tests cannot: the server routes the @Controller classes
103+
// compose to have to RESOLVE against the SDK's client calls and draw a cross-repo
104+
// edge. Before v142 TypeScript had no server-side route DSL at all, so the api
105+
// repo emitted zero routes and was classified `isolated` while every SDK call sat
106+
// unresolved. Covers both argument forms (@Controller({path}) and
107+
// @Controller("…")), a bare @Get() serving the class path, the InversifyJS
108+
// vocabulary, and — by its absence from the golden — a verb decorator on a
109+
// non-controller class minting nothing.
110+
{name: "ts_nest_multirepo", subRepos: []string{"api", "sdk"}},
111+
// A call-routed Express server plus a consumer that calls it. Pins v143's three
112+
// rules, none of which a unit test can prove end to end: (a) receiver binding
113+
// separates registrations from v141's identically-shaped client calls, so no call
114+
// site is emitted twice and no client route is reclassified; (b) a sub-router
115+
// mounted in the SAME file composes ('/admin/users'), while one mounted from
116+
// another file emits nothing rather than a wrong fragment path ('/login'); and
117+
// (c) a bare catch-all is not an endpoint. The consumer's fourth call is served by
118+
// nobody, so it stays unresolved — the control that the linker is matching real
119+
// paths rather than accepting anything.
120+
{name: "ts_express_multirepo", subRepos: []string{"server", "consumer"}},
101121
}
102122

103123
func TestGolden(t *testing.T) {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{"kind":"dependency","name":". -\u003e controllers/default","file":"server/index.js","line":25,"repo":"server","props":{"dynamic":true,"language":"typescript","source":"internal"},"relations":[{"kind":"imports","target":"controllers/default"}]}
2+
{"kind":"dependency","name":". -\u003e express","file":"server/index.js","line":3,"repo":"server","props":{"dynamic":true,"language":"typescript","source":"external"},"relations":[{"kind":"imports","target":"express"}]}
3+
{"kind":"dependency","name":". -\u003e routes/webhooks","file":"server/index.js","line":5,"repo":"server","props":{"dynamic":true,"language":"typescript","source":"internal"},"relations":[{"kind":"imports","target":"routes/webhooks"}]}
4+
{"kind":"dependency","name":"consumer -\u003e server","repo":"consumer","props":{"confidence":"verified","endpoint_count":2,"endpoints":["GET /admin/users","POST /admin/users/{}/ban"],"synthetic":"crossrepo","type":"cross_repo","via":["http-client"]}}
5+
{"kind":"dependency","name":"routes -\u003e express","file":"server/routes/webhooks.js","line":9,"repo":"server","props":{"dynamic":true,"language":"typescript","source":"external"},"relations":[{"kind":"imports","target":"express"}]}
6+
{"kind":"dependency","name":"src -\u003e axios","file":"consumer/src/client.ts","line":1,"repo":"consumer","props":{"language":"typescript","source":"external"},"relations":[{"kind":"imports","target":"axios"}]}
7+
{"kind":"file_ref","name":"index.js","file":"server/index.js","line":1,"repo":"server","props":{"language":"typescript"},"relations":[{"kind":"calls","target":"..admin"},{"kind":"calls","target":"..banUser"},{"kind":"calls","target":"..healthCheckController"},{"kind":"calls","target":"..listUsers"},{"kind":"calls","target":"..proxyLink"},{"kind":"calls","target":"..require"},{"kind":"calls","target":"routes.webhookRoutes"}]}
8+
{"kind":"file_ref","name":"routes/webhooks.js","file":"server/routes/webhooks.js","line":1,"repo":"server","props":{"language":"typescript"},"relations":[{"kind":"calls","target":"routes.require"}]}
9+
{"kind":"module","name":".","file":"server/.","repo":"server","props":{"language":"typescript","package_name":"express-server"}}
10+
{"kind":"module","name":"routes","file":"server/routes","repo":"server","props":{"language":"typescript","package_name":"express-server"}}
11+
{"kind":"module","name":"src","file":"consumer/src","repo":"consumer","props":{"language":"typescript","package_name":"express-consumer"}}
12+
{"kind":"route","name":"/admin/users","file":"consumer/src/client.ts","line":17,"repo":"consumer","props":{"api":"client","framework":"axios","language":"typescript","method":"GET","role":"client","source":"ts-http-client"},"relations":[{"kind":"declares","target":"src"}]}
13+
{"kind":"route","name":"/admin/users","file":"server/index.js","line":19,"repo":"server","props":{"framework":"express","language":"typescript","method":"GET","role":"server"},"relations":[{"kind":"declares","target":"."}]}
14+
{"kind":"route","name":"/admin/users/:id/ban","file":"server/index.js","line":20,"repo":"server","props":{"framework":"express","language":"typescript","method":"POST","role":"server"},"relations":[{"kind":"declares","target":"."}]}
15+
{"kind":"route","name":"/admin/users/{}/ban","file":"consumer/src/client.ts","line":21,"repo":"consumer","props":{"api":"client","framework":"axios","language":"typescript","method":"POST","role":"client","source":"ts-http-client"},"relations":[{"kind":"declares","target":"src"}]}
16+
{"kind":"route","name":"/go/:name","file":"server/index.js","line":10,"repo":"server","props":{"framework":"express","language":"typescript","method":"GET","role":"server","unmatched_by_clients":true},"relations":[{"kind":"declares","target":"."}]}
17+
{"kind":"route","name":"/healthcheck","file":"consumer/src/client.ts","line":13,"repo":"consumer","props":{"api":"client","framework":"axios","language":"typescript","method":"GET","role":"client","source":"ts-http-client","unmatched_by_server":true,"unmatched_reason":"generic_path"},"relations":[{"kind":"declares","target":"src"}]}
18+
{"kind":"route","name":"/healthcheck","file":"server/index.js","line":8,"repo":"server","props":{"framework":"express","language":"typescript","method":"GET","role":"server"},"relations":[{"kind":"declares","target":"."}]}
19+
{"kind":"route","name":"/healthcheck","file":"server/index.js","line":9,"repo":"server","props":{"framework":"express","language":"typescript","method":"OPTIONS","role":"server"},"relations":[{"kind":"declares","target":"."}]}
20+
{"kind":"route","name":"/not/served/anywhere","file":"consumer/src/client.ts","line":28,"repo":"consumer","props":{"api":"client","framework":"axios","language":"typescript","method":"GET","role":"client","source":"ts-http-client","unmatched_by_server":true,"unmatched_reason":"path_unknown"},"relations":[{"kind":"declares","target":"src"}]}
21+
{"kind":"service","name":"consumer","repo":"consumer","props":{"edge_coverage":[{"detected":4,"edge_type":"http_client","external":0,"resolved":2,"unresolved":2}],"synthetic":"crossrepo"},"relations":[{"kind":"depends_on","target":"server"}]}
22+
{"kind":"service","name":"server","repo":"server","props":{"synthetic":"crossrepo"}}
23+
{"kind":"symbol","name":"..admin","file":"server/index.js","line":18,"repo":"server","props":{"exported":false,"language":"typescript","symbol_kind":"variable"},"relations":[{"kind":"declares","target":"."}]}
24+
{"kind":"symbol","name":"..app","file":"server/index.js","line":3,"repo":"server","props":{"exported":false,"language":"typescript","symbol_kind":"variable"},"relations":[{"kind":"declares","target":"."}]}
25+
{"kind":"symbol","name":"..express","file":"server/index.js","line":4,"repo":"server","props":{"exported":false,"language":"typescript","symbol_kind":"variable"},"relations":[{"kind":"declares","target":"."}]}
26+
{"kind":"symbol","name":"..webhookRoutes","file":"server/index.js","line":5,"repo":"server","props":{"exported":false,"language":"typescript","symbol_kind":"variable"},"relations":[{"kind":"declares","target":"."}]}
27+
{"kind":"symbol","name":"routes.express","file":"server/routes/webhooks.js","line":9,"repo":"server","props":{"exported":false,"language":"typescript","symbol_kind":"variable"},"relations":[{"kind":"declares","target":"routes"}]}
28+
{"kind":"symbol","name":"routes.router","file":"server/routes/webhooks.js","line":10,"repo":"server","props":{"exported":false,"language":"typescript","symbol_kind":"variable"},"relations":[{"kind":"declares","target":"routes"}]}
29+
{"kind":"symbol","name":"src.banUser","file":"consumer/src/client.ts","line":20,"repo":"consumer","props":{"cyclomatic":1,"exported":true,"io_direct":true,"language":"typescript","performs_io":true,"symbol_kind":"function"},"relations":[{"kind":"declares","target":"src"}]}
30+
{"kind":"symbol","name":"src.health","file":"consumer/src/client.ts","line":12,"repo":"consumer","props":{"cyclomatic":1,"exported":true,"io_direct":true,"language":"typescript","performs_io":true,"symbol_kind":"function"},"relations":[{"kind":"declares","target":"src"}]}
31+
{"kind":"symbol","name":"src.listAdminUsers","file":"consumer/src/client.ts","line":16,"repo":"consumer","props":{"cyclomatic":1,"exported":true,"io_direct":true,"language":"typescript","performs_io":true,"symbol_kind":"function"},"relations":[{"kind":"declares","target":"src"}]}
32+
{"kind":"symbol","name":"src.unknown","file":"consumer/src/client.ts","line":27,"repo":"consumer","props":{"cyclomatic":1,"exported":true,"io_direct":true,"language":"typescript","performs_io":true,"symbol_kind":"function"},"relations":[{"kind":"declares","target":"src"}]}

0 commit comments

Comments
 (0)