@@ -1148,12 +1148,166 @@ def role_of(fqn: str) -> str:
11481148 "(PR-P4 regression: skip-if-exists dropped the upsert)"
11491149 )
11501150
1151- def test_incremental_route_merge_dedup_preserved (self , tmp_path : Path ) -> None :
1152- """Pass5/6 Route MERGE dedup is preserved after bulk conversion.
1151+ def test_incremental_pulls_in_annotation_users_on_def_change (
1152+ self , tmp_path : Path
1153+ ) -> None :
1154+ """Editing an annotation definition refreshes its direct users' roles.
1155+
1156+ Regression guard for the PR-P5b fix. Annotation usage is a node property
1157+ (``annotations`` STRING[]), not a Symbol->Symbol edge, so `_find_dependents`
1158+ — which walks edges — never pulls annotation users into scope. When an
1159+ annotation definition changes (e.g. ``@interface MyService`` gains a
1160+ ``@Service`` meta-annotation that shifts the Layer-A chain), types
1161+ carrying ``@MyService`` need their ``role`` recomputed or they go stale.
1162+
1163+ Unlike `test_incremental_refreshes_dependent_role_on_meta_chain_change`
1164+ (PR-P4), the user here has NO edge to any changed node: it is pulled in
1165+ purely by the annotation-usage expansion (`_find_annotation_dependents`),
1166+ so this isolates the scope-tracking fix from the preserved-dependent
1167+ property refresh.
1168+ """
1169+ from build_ast_graph import incremental_rebuild
1170+ from graph_enrich import collect_annotation_meta_chain
1171+ from _builders import build_ladybug_full_into
1172+
1173+ source_root = tmp_path / "src"
1174+ source_root .mkdir ()
1175+ index_dir = tmp_path / "index"
1176+ index_dir .mkdir ()
1177+ ladybug_path = index_dir / "code_graph.lbug"
1178+ java = source_root / "pkg"
1179+ java .mkdir (parents = True )
1180+
1181+ # Svc carries @MyService but calls/extends nothing — no edge to any other
1182+ # node, so `_find_dependents` cannot pull it in. Only annotation usage can.
1183+ (java / "MyService.java" ).write_text (
1184+ "package pkg; public @interface MyService {}\n " , encoding = "utf-8"
1185+ )
1186+ (java / "Svc.java" ).write_text (
1187+ "package pkg;\n @MyService\n public class Svc {}\n " , encoding = "utf-8"
1188+ )
1189+ build_ladybug_full_into (source_root , ladybug_path )
1190+
1191+ # Edit ONLY the annotation definition: add a @Service meta-annotation so
1192+ # the chain maps MyService → Service and Svc's role flips OTHER → SERVICE.
1193+ (java / "MyService.java" ).write_text (
1194+ "package pkg;\n import org.springframework.stereotype.Service;\n "
1195+ "@Service\n public @interface MyService {}\n " ,
1196+ encoding = "utf-8" ,
1197+ )
1198+ collect_annotation_meta_chain .cache_clear ()
1199+ result = incremental_rebuild (source_root , ladybug_path , verbose = False )
1200+ assert result .mode == "incremental" , f"expected incremental, got { result .mode } "
1201+ assert result .dependents_reprocessed >= 1 , (
1202+ "Svc should be pulled in as an annotation-dependent, not just the def file"
1203+ )
1204+
1205+ def role_of (fqn : str ) -> str :
1206+ db = ladybug .Database (str (ladybug_path ))
1207+ conn = ladybug .Connection (db )
1208+ r = conn .execute ("MATCH (n:Symbol {fqn: $fqn}) RETURN n.role" , {"fqn" : fqn })
1209+ v = r .get_next ()[0 ] if r .has_next () else None
1210+ conn .close ()
1211+ db .close ()
1212+ return v
1213+
1214+ # Svc was pulled in by annotation usage (no edge to the changed def); its
1215+ # role must refresh to SERVICE to match a full rebuild of this state.
1216+ assert role_of ("pkg.Svc" ) == "SERVICE" , (
1217+ "annotation user's role not refreshed after annotation-def change "
1218+ "(PR-P5b regression: annotation users not pulled into incremental scope)"
1219+ )
11531220
1154- Creates a corpus where pass5/6 re-emits an existing route and verifies
1155- no duplicate Route rows after incremental rebuild (the retained MERGE
1156- dedups against routes written during the scoped step).
1221+ def test_incremental_overrides_not_duplicated_for_non_scope_subtype (
1222+ self , tmp_path : Path
1223+ ) -> None :
1224+ """A non-scope subtype's OVERRIDES edge is not duplicated on increment.
1225+
1226+ Invariant guard for the OVERRIDES path. Unlike DECLARES (derived purely
1227+ from a member's own ``parent_id``/``node_id``, which is why a loaded
1228+ non-scope member could duplicate it — fixed in PR-P4), an OVERRIDES pair
1229+ needs the subtype's supertype via `_direct_supertype_ids`, which reads
1230+ `tables.extends_rows`/`implements_rows`. Those edge tables are populated
1231+ only by `pass2_edges` over *parsed* files; cross-file resolution stubs
1232+ (`loaded_from_db`) load nodes but NOT edges, so a loaded subtype has no
1233+ derivable supertype and `_populate_overrides_rows` never re-emits its
1234+ OVERRIDES — the edge (``source_file`` = its out-of-scope file) survives
1235+ untouched, matching a full rebuild.
1236+
1237+ This test pins that behavior down. If stub edge-loading is ever added as
1238+ an optimization, this guard will catch the resulting duplication (the
1239+ same class of bug PR-P4 fixed for DECLARES).
1240+
1241+ Corpus: `TImpl implements T` overrides `foo()` in non-scope files; an
1242+ unrelated `Other.java` change triggers the increment so `TImpl`/`T` are
1243+ loaded as stubs. The increment must keep exactly one OVERRIDES edge.
1244+ """
1245+ from build_ast_graph import incremental_rebuild
1246+ from _builders import build_ladybug_full_into
1247+
1248+ source_root = tmp_path / "src"
1249+ source_root .mkdir ()
1250+ index_dir = tmp_path / "index"
1251+ index_dir .mkdir ()
1252+ ladybug_path = index_dir / "code_graph.lbug"
1253+ java = source_root / "pkg"
1254+ java .mkdir (parents = True )
1255+
1256+ (java / "T.java" ).write_text (
1257+ "package pkg; public interface T { void foo(); }\n " , encoding = "utf-8"
1258+ )
1259+ (java / "TImpl.java" ).write_text (
1260+ "package pkg; public class TImpl implements T { public void foo() {} }\n " ,
1261+ encoding = "utf-8" ,
1262+ )
1263+ (java / "Other.java" ).write_text (
1264+ "package pkg; public class Other { public void go() {} }\n " , encoding = "utf-8"
1265+ )
1266+ build_ladybug_full_into (source_root , ladybug_path )
1267+
1268+ # Change Other.java only. Nothing references Other, so the scope is just
1269+ # {Other.java}; TImpl/T are non-scope and loaded as resolution stubs.
1270+ (java / "Other.java" ).write_text (
1271+ "package pkg; public class Other { public void go() {} public void more() {} }\n " ,
1272+ encoding = "utf-8" ,
1273+ )
1274+ result = incremental_rebuild (source_root , ladybug_path , verbose = False )
1275+ assert result .mode == "incremental" , f"expected incremental, got { result .mode } "
1276+
1277+ def overrides_count (path : Path ) -> int :
1278+ db = ladybug .Database (str (path ))
1279+ conn = ladybug .Connection (db )
1280+ r = conn .execute ("MATCH ()-[o:OVERRIDES]->() RETURN count(o)" )
1281+ n = r .get_next ()[0 ] if r .has_next () else 0
1282+ conn .close ()
1283+ db .close ()
1284+ return n
1285+
1286+ # Exactly one override relationship exists (TImpl.foo → T.foo); a
1287+ # duplicate (2) is the bug. Cross-check against a fresh full rebuild of
1288+ # the identical final state — the increment must match it.
1289+ full_dir = tmp_path / "full"
1290+ full_dir .mkdir ()
1291+ full_path = full_dir / "code_graph.lbug"
1292+ build_ladybug_full_into (source_root , full_path )
1293+
1294+ assert overrides_count (ladybug_path ) == 1 , (
1295+ "non-scope OVERRIDES edge duplicated on increment "
1296+ "(PR-P5a regression: loaded subtype method re-emitted)"
1297+ )
1298+ assert overrides_count (ladybug_path ) == overrides_count (full_path ), (
1299+ "incremental OVERRIDES count diverged from full rebuild"
1300+ )
1301+
1302+ def test_incremental_route_merge_dedup_preserved (self , tmp_path : Path ) -> None :
1303+ """Pass5/6 Route write does not duplicate existing routes.
1304+
1305+ Creates a corpus where pass5/6 re-emits an existing route and verifies
1306+ no duplicate Route rows after incremental rebuild. The global step now
1307+ bulk-writes routes (COPY new ids + SET existing ids — see PR-P5c),
1308+ replacing the per-row MERGE upsert this test was originally written for;
1309+ the name is kept for plan-reference continuity. The SET branch is what
1310+ dedups against routes written during the scoped step here.
11571311 """
11581312 from build_ast_graph import incremental_rebuild , write_ladybug
11591313
@@ -1216,7 +1370,7 @@ def test_incremental_route_merge_dedup_preserved(self, tmp_path: Path) -> None:
12161370 route_count = route_count_result .get_next ()[0 ]
12171371
12181372 # Should be exactly 1 route (no duplicates)
1219- assert route_count == 1 , f"Expected 1 route, found { route_count } (MERGE dedup failed)"
1373+ assert route_count == 1 , f"Expected 1 route, found { route_count } (route dedup failed)"
12201374
12211375 conn .close ()
12221376
0 commit comments