88
99from __future__ import annotations
1010
11+ from pathlib import Path
12+
1113from loadpath .architecture .rules import Finding
1214from loadpath .config import LoadpathConfig
1315from loadpath .graph .store import GraphStore
@@ -128,18 +130,24 @@ def _card_for(finding: Finding) -> dict | None:
128130def _leaked_seams (store : GraphStore ) -> list [Finding ]:
129131 views = {n ["id" ]: n for n in store .nodes ([NodeType .VIEW ])}
130132 models = {n ["id" ]: n for n in store .nodes ([NodeType .MODEL ])}
131- services = [n for n in store .nodes ([NodeType .SERVICE ]) if not (n .get ("extra" ) or {}).get ("referenced" )]
132- services_by_ctx : dict [str , list [dict ]] = {}
133- for svc in services :
134- ctx = svc .get ("context" ) or ""
135- services_by_ctx .setdefault (ctx , []).append (svc )
133+ services = [
134+ n for n in store .nodes ([NodeType .SERVICE ]) if not (n .get ("extra" ) or {}).get ("referenced" )
135+ ]
136+ queries_by_src : dict [str , set [str ]] = {}
136137 called_by_view : dict [str , set [str ]] = {v : set () for v in views }
137138 for edge in store .edges ():
138- if edge ["type" ] != EdgeType .CALLS .value :
139- continue
140- if edge ["src" ] in called_by_view :
139+ if edge ["type" ] == EdgeType .QUERIES_MODEL .value :
140+ queries_by_src . setdefault ( edge [ "src" ], set ()). add ( edge [ "dst" ])
141+ elif edge [ "type" ] == EdgeType . CALLS . value and edge ["src" ] in called_by_view :
141142 called_by_view [edge ["src" ]].add (edge ["dst" ])
143+ modules_by_ctx : dict [str , list [dict ]] = {}
144+ for svc in services :
145+ ctx = svc .get ("context" ) or ""
146+ if not ctx or not svc .get ("file_path" ):
147+ continue
148+ modules_by_ctx .setdefault (ctx , []).append (svc )
142149 out : list [Finding ] = []
150+ seen : set [tuple [str , str ]] = set ()
143151 for edge in store .edges ():
144152 if edge ["type" ] != EdgeType .QUERIES_MODEL .value :
145153 continue
@@ -150,41 +158,57 @@ def _leaked_seams(store: GraphStore) -> list[Finding]:
150158 if (edge .get ("extra" ) or {}).get ("imported" ):
151159 continue
152160 ctx = view .get ("context" ) or ""
161+ if not ctx :
162+ continue
163+ key = (view ["id" ], model ["id" ])
164+ if key in seen :
165+ continue
166+ called = called_by_view .get (view ["id" ], set ())
153167 peers = [
154168 s
155- for s in services_by_ctx .get (ctx , [])
156- if s ["id" ] not in called_by_view .get (view ["id" ], set ())
157- and s .get ("file_path" ) != view .get ("file_path" )
169+ for s in modules_by_ctx .get (ctx , [])
170+ if s .get ("file_path" ) != view .get ("file_path" )
158171 ]
159172 if not peers :
160173 continue
161- peer = peers [0 ]
174+ # Prefer a service that already queries this model; otherwise the services
175+ # module is the seam — not an arbitrary unused function in the same context.
176+ same_model = [s for s in peers if model ["id" ] in queries_by_src .get (s ["id" ], set ())]
177+ same_model .sort (key = lambda s : s ["name" ])
178+ unused = [s for s in peers if s ["id" ] not in called ]
179+ unused .sort (key = lambda s : s ["name" ])
180+ if same_model :
181+ peer_name = same_model [0 ]["name" ]
182+ else :
183+ files = sorted ({s ["file_path" ] for s in (unused or peers )})
184+ peer_name = Path (files [0 ]).stem
185+ seen .add (key )
162186 out .append (
163187 Finding (
164188 rule = "leaked_seam" ,
165189 severity = RuleSeverity .WARNING ,
166190 message = (
167191 f"{ view ['name' ]} queries { model .get ('qualified_name' )} past the "
168- f"{ peer [ 'name' ] } module's seam. Callers of the view learn the queryset; "
192+ f"{ peer_name } module's seam. Callers of the view learn the queryset; "
169193 f"depth (leverage at the interface) is lost."
170194 ),
171195 node_id = view ["id" ],
172196 file_path = view .get ("file_path" ),
173197 extra = {
174198 "strength" : "strong" ,
175199 "module" : view ["name" ],
176- "query_module" : peer [ "name" ] ,
200+ "query_module" : peer_name ,
177201 "model" : model .get ("qualified_name" ),
178202 "deletion_test" : (
179- f"Deleting { peer [ 'name' ] } would not concentrate complexity — the view already "
203+ f"Deleting { peer_name } would not concentrate complexity — the view already "
180204 f"owns the queryset. Deleting the view's queryset would reappear on every action."
181205 ),
182206 "leverage" : (
183207 f"One query module interface would pay back across { view ['name' ]} actions and tests."
184208 ),
185209 "locality" : "Queryset shape, select_related, and auth scoping should live in one module." ,
186210 "before" : f"{ view ['name' ]} → { model .get ('name' )} (queryset in the view)" ,
187- "after" : f"{ view ['name' ]} → { peer [ 'name' ] } → { model .get ('name' )} " ,
211+ "after" : f"{ view ['name' ]} → { peer_name } → { model .get ('name' )} " ,
188212 },
189213 )
190214 )
0 commit comments