@@ -21,8 +21,21 @@ defmodule GameServer.ApiConventions do
2121 message: String . t ( )
2222 }
2323
24- @ core "apps/game_server_core/lib"
25- @ web "apps/game_server_web/lib"
24+ # The same checks run in every host repo (polyglot, the starter), where
25+ # core/web live under deps and the host's own code under lib/ and
26+ # modules/*/lib - so the scan roots are discovered, not hardcoded. Only
27+ # directories that exist take part.
28+ @ schema_roots [ "apps/game_server_core/lib" , "lib" , "modules" ]
29+ @ source_roots [ "apps/game_server_web/lib" , "lib" , "modules" ]
30+
31+ defp schema_dirs , do: existing ( [ dep_dir ( "game_server_core" ) | @ schema_roots ] )
32+ defp source_dirs , do: existing ( [ dep_dir ( "game_server_web" ) | @ source_roots ] )
33+
34+ # In a host repo core/web are deps; their code obeys the rules too, and a
35+ # violation there means the host pulled a bad version - worth failing on.
36+ defp dep_dir ( app ) , do: Path . join ( [ "deps" , app , "apps" , app , "lib" ] )
37+
38+ defp existing ( paths ) , do: paths |> Enum . filter ( & File . dir? / 1 ) |> Enum . uniq ( )
2639
2740 # Durations must name their unit. `_at` is for instants, not durations.
2841 @ duration_units ~w( ms sec seconds min minutes hours days)
@@ -39,7 +52,8 @@ defmodule GameServer.ApiConventions do
3952 hyphenated_route_paths ( ) ++
4053 nullable_string_schemas ( ) ++
4154 hand_rolled_meta ( ) ++
42- hand_rolled_page_params ( ) )
55+ hand_rolled_page_params ( ) ++
56+ stale_documented_routes ( ) )
4357 |> Enum . sort_by ( & { & 1 . rule , & 1 . file , & 1 . line } )
4458 end
4559
@@ -51,7 +65,10 @@ defmodule GameServer.ApiConventions do
5165 defp nullable_strings_not_coalesced do
5266 nullable = nullable_schema_fields ( )
5367
54- for { file , line , text } <- source_lines ( @ web ) ,
68+ for { file , line , text } <- source_lines ( source_dirs ( ) ) ,
69+ # Only code that writes JSON responses: a `key: var.field` line in a
70+ # context is usually changeset attrs, where nil is fine.
71+ String . contains? ( file , "_web/" ) ,
5572 not live_view? ( file ) ,
5673 [ _ , key , _var , field ] <- [ Regex . run ( ~r/ ^\s *(\w +): ([a-z_]+)\. (\w +),?\s *$/ , text ) ] ,
5774 Map . has_key? ( nullable , field ) ,
@@ -71,7 +88,7 @@ defmodule GameServer.ApiConventions do
7188 # every serializer. Such schemas encode through `GameServer.SchemaJSON`.
7289
7390 defp derived_encoders_with_nullable_strings do
74- for { file , line , text } <- source_lines ( @ core ) ,
91+ for { file , line , text } <- source_lines ( schema_dirs ( ) ) ,
7592 String . contains? ( text , "@derive {Jason.Encoder" ) ,
7693 schema_has_nullable_string? ( file ) do
7794 % {
@@ -86,7 +103,7 @@ defmodule GameServer.ApiConventions do
86103 # ── R3: instants are `*_at`, and nothing else is ───────────────────────────
87104
88105 defp instants_not_suffixed do
89- for { file , line , text } <- source_lines ( @ core ) ,
106+ for { file , line , text } <- source_lines ( schema_dirs ( ) ) ,
90107 [ _ , field ] <- [ Regex . run ( ~r/ ^\s *field :(\w +), :utc_datetime/ , text ) ] ,
91108 not String . ends_with? ( field , "_at" ) do
92109 % {
@@ -101,7 +118,7 @@ defmodule GameServer.ApiConventions do
101118 # ── R4: durations name their unit ──────────────────────────────────────────
102119
103120 defp durations_without_unit do
104- for { file , line , text } <- source_lines ( @ core ) ++ source_lines ( @ web ) ,
121+ for { file , line , text } <- source_lines ( schema_dirs ( ) ++ source_dirs ( ) ) ,
105122 [ _ , name ] <- [ Regex . run ( ~r/ ^\s *setting\( ?:(\w +), :integer/ , text ) ] ,
106123 duration_name? ( name ) ,
107124 not unit_suffixed? ( name ) do
@@ -124,7 +141,7 @@ defmodule GameServer.ApiConventions do
124141 # ── R5: route paths use underscores ────────────────────────────────────────
125142
126143 defp hyphenated_route_paths do
127- for { file , line , text } <- source_lines ( @ web ) ,
144+ for { file , line , text } <- source_lines ( source_dirs ( ) ) ,
128145 String . contains? ( file , "router" ) ,
129146 [ _ , verb , path ] <-
130147 [ Regex . run ( ~r/ ^\s *(get|post|put|patch|delete|live) "(\/ [^"]*)"/ , text ) ] ,
@@ -145,7 +162,7 @@ defmodule GameServer.ApiConventions do
145162 # semantic there (`ends_at: null` = permanent).
146163
147164 defp nullable_string_schemas do
148- for { file , line , text } <- source_lines ( @ web ) ,
165+ for { file , line , text } <- source_lines ( source_dirs ( ) ) ,
149166 String . contains? ( text , "nullable: true" ) ,
150167 String . contains? ( text , "type: :string" ) ,
151168 not String . contains? ( text , "format:" ) ,
@@ -166,7 +183,7 @@ defmodule GameServer.ApiConventions do
166183 # emitting three, four and six keys.
167184
168185 defp hand_rolled_meta do
169- for { file , line , text } <- source_lines ( @ web ) ,
186+ for { file , line , text } <- source_lines ( source_dirs ( ) ) ,
170187 controller? ( file ) ,
171188 Regex . match? ( ~r/ ^\s *(total_pages|has_more):/ , text ) ,
172189 not String . contains? ( text , "%Schema{" ) do
@@ -185,7 +202,7 @@ defmodule GameServer.ApiConventions do
185202 # `min(size, 100)`, which ignored the configurable `max_page_size` limit.
186203
187204 defp hand_rolled_page_params do
188- for { file , line , text } <- source_lines ( @ web ) ,
205+ for { file , line , text } <- source_lines ( source_dirs ( ) ) ,
189206 controller? ( file ) ,
190207 String . contains? ( text , ~s( params["page_size"]) ) or
191208 String . contains? ( text , ~s( params["page"]) ) do
@@ -200,6 +217,91 @@ defmodule GameServer.ApiConventions do
200217
201218 defp controller? ( file ) , do: String . contains? ( file , "/controllers/" )
202219
220+ # ── R9: docs reference only routes that exist ──────────────────────────────
221+ #
222+ # Guides and specs name concrete `/api/v1/...` paths; a rename that misses
223+ # one leaves documentation teaching a 404. Prefixes of real routes are fine
224+ # ("endpoints live under /api/v1/chat"), as are file paths.
225+
226+ @ doc_dirs [ "priv/docs" , "docs" ]
227+
228+ defp stale_documented_routes do
229+ case declared_route_paths ( ) do
230+ [ ] -> [ ]
231+ declared -> stale_documented_routes ( declared )
232+ end
233+ end
234+
235+ defp stale_documented_routes ( declared ) do
236+ for dir <- @ doc_dirs ,
237+ file <- Path . wildcard ( Path . join ( dir , "**/*.md" ) ) ,
238+ { text , line } <- file |> File . read! ( ) |> String . split ( "\n " ) |> Enum . with_index ( 1 ) ,
239+ path <- documented_api_paths ( text ) ,
240+ not known_or_prefix? ( path , declared ) do
241+ % {
242+ rule: "R9-doc-route" ,
243+ file: file ,
244+ line: line ,
245+ message: "documents `#{ path } `, which matches no declared route"
246+ }
247+ end
248+ end
249+
250+ defp documented_api_paths ( line ) do
251+ ~r| /api/v1/[A-Za-z0-9_\- /:{}]*[A-Za-z0-9_}]|
252+ |> Regex . scan ( line )
253+ |> List . flatten ( )
254+ |> Enum . reject ( & ( String . contains? ( & 1 , "controller" ) or String . ends_with? ( & 1 , ".ex" ) ) )
255+ |> Enum . map ( & normalize_path / 1 )
256+ end
257+
258+ defp normalize_path ( path ) do
259+ Regex . replace ( ~r/ (:\w +|\{ \w +\} )/ , path , "{}" )
260+ end
261+
262+ # A documented path is fine when a declared route matches it — params on
263+ # either side are wildcards, so `/payments/validate/google` satisfies
264+ # `/payments/validate/{}` — or when it is a prefix of one ("endpoints live
265+ # under /api/v1/chat").
266+ defp known_or_prefix? ( path , declared ) do
267+ Enum . any? ( declared , fn route ->
268+ Regex . match? ( wildcard_regex ( route , "" ) , path ) or
269+ Regex . match? ( wildcard_regex ( path , "(/|$)" ) , route )
270+ end )
271+ end
272+
273+ defp wildcard_regex ( pattern , tail ) do
274+ inner =
275+ pattern
276+ |> Regex . escape ( )
277+ |> String . replace ( "\\ {\\ }" , "[^/]+" )
278+
279+ Regex . compile! ( "^" <> inner <> if ( tail == "" , do: "$" , else: tail ) )
280+ end
281+
282+ @ doc """
283+ Every path the compiled router serves, `:params` as `{}`.
284+
285+ Read from `Phoenix.Router.routes/1` rather than parsed from source — routes
286+ live inside `scope` blocks, so the literal strings in the source are
287+ suffixes, not full paths.
288+ """
289+ @ spec declared_route_paths ( ) :: [ String . t ( ) ]
290+ def declared_route_paths do
291+ [ GameServerHost.Router , GameServerWeb.Router ]
292+ |> Enum . find ( & Code . ensure_loaded? / 1 )
293+ |> case do
294+ nil ->
295+ [ ]
296+
297+ router ->
298+ router
299+ |> Phoenix.Router . routes ( )
300+ |> Enum . map ( & normalize_path ( & 1 . path ) )
301+ |> Enum . uniq ( )
302+ end
303+ end
304+
203305 # ── Shared analysis ────────────────────────────────────────────────────────
204306
205307 @ doc """
@@ -209,7 +311,7 @@ defmodule GameServer.ApiConventions do
209311 @ spec nullable_schema_fields ( ) :: % { String . t ( ) => String . t ( ) }
210312 def nullable_schema_fields do
211313 per_schema =
212- for path <- ex_files ( @ core ) ,
314+ for path <- ex_files ( schema_dirs ( ) ) ,
213315 [ _ , table , body ] <- [ Regex . run ( ~r/ schema "(\w +)" do(.*?)\n end/ s , File . read! ( path ) ) ] do
214316 required = required_fields ( File . read! ( path ) )
215317
@@ -271,13 +373,24 @@ defmodule GameServer.ApiConventions do
271373 # policy is about what crosses the wire as JSON.
272374 defp live_view? ( file ) , do: String . contains? ( file , "/live/" )
273375
274- defp source_lines ( root ) do
275- for path <- ex_files ( root ) ,
376+ defp source_lines ( roots ) do
377+ for path <- ex_files ( roots ) ,
276378 { text , line } <- Enum . with_index ( String . split ( File . read! ( path ) , "\n " ) , 1 ) ,
277379 do: { path , line , text }
278380 end
279381
280- defp ex_files ( root ) do
281- Path . wildcard ( Path . join ( root , "**/*.ex" ) )
382+ defp ex_files ( roots ) when is_list ( roots ) do
383+ roots
384+ |> Enum . flat_map ( fn root ->
385+ # A root may itself live under deps/ (core/web pulled into a host), but
386+ # anything *nested* below a root - a plugin's own deps/_build - is
387+ # third-party code and not ours to lint.
388+ for path <- Path . wildcard ( Path . join ( root , "**/*.ex" ) ) ,
389+ rest = String . trim_leading ( path , root ) ,
390+ not String . contains? ( rest , "/deps/" ) ,
391+ not String . contains? ( rest , "/_build/" ) ,
392+ do: path
393+ end )
394+ |> Enum . uniq ( )
282395 end
283396end
0 commit comments