java-codebase-rag 0.6.4__py3-none-any.whl → 0.6.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
build_ast_graph.py CHANGED
@@ -39,6 +39,7 @@ from dataclasses import asdict, dataclass, field, replace
39
39
  from pathlib import Path
40
40
 
41
41
  import ladybug
42
+ import pyarrow as pa
42
43
 
43
44
  from ast_java import (
44
45
  ONTOLOGY_VERSION,
@@ -187,6 +188,12 @@ class TypeIndexEntry:
187
188
  package: str
188
189
  outer_fqn: str | None
189
190
  node_id: str
191
+ # True when this entry was loaded from the existing graph by
192
+ # `_load_existing_types` (an unchanged-file stub used only for cross-file
193
+ # resolution). Its `decl` is a placeholder (no annotations/methods), so its
194
+ # recomputed role/capabilities must never be written back over the real
195
+ # stored values. See `_write_nodes_impl`.
196
+ loaded_from_db: bool = False
190
197
 
191
198
 
192
199
  @dataclass
@@ -199,6 +206,11 @@ class MemberEntry:
199
206
  module: str
200
207
  microservice: str
201
208
  node_id: str
209
+ # True when loaded from the existing graph by `_load_existing_members`
210
+ # (an unchanged-file stub used only for cross-file call resolution). Its
211
+ # DECLARES edge already persists in the graph, so it must not be re-emitted
212
+ # by `_populate_declares_rows` (REL tables have no PK → would duplicate).
213
+ loaded_from_db: bool = False
202
214
 
203
215
 
204
216
  @dataclass
@@ -555,7 +567,7 @@ def _load_existing_types(conn: ladybug.Connection, tables: GraphTables, exclude_
555
567
  if exclude_files is not None and not exclude_files:
556
568
  return
557
569
 
558
- where = "WHERE s.kind IN ['class', 'interface', 'enum', 'annotation', 'record']"
570
+ where = f"WHERE s.kind IN {list(_TYPE_KINDS)}"
559
571
  params: dict = {}
560
572
  if exclude_files:
561
573
  where += "\n AND NOT (s.filename IN $exclude_files)"
@@ -585,6 +597,7 @@ def _load_existing_types(conn: ladybug.Connection, tables: GraphTables, exclude_
585
597
  package=package,
586
598
  outer_fqn=None,
587
599
  node_id=node_id,
600
+ loaded_from_db=True,
588
601
  )
589
602
  tables.types[fqn] = entry
590
603
  tables.by_simple_name.setdefault(name, []).append(entry)
@@ -633,6 +646,7 @@ def _load_existing_members(conn: ladybug.Connection, tables: GraphTables, exclud
633
646
  module="",
634
647
  microservice="",
635
648
  node_id=node_id,
649
+ loaded_from_db=True,
636
650
  ))
637
651
 
638
652
 
@@ -670,6 +684,54 @@ def _find_dependents(conn: ladybug.Connection, changed_node_ids: set[str]) -> se
670
684
  return dependent_files
671
685
 
672
686
 
687
+ def _find_annotation_dependents(conn: ladybug.Connection, changed_node_ids: set[str]) -> set[str]:
688
+ """Find files that USE an annotation whose DEFINITION is among the changed nodes.
689
+
690
+ Annotation usage is a node property (``annotations`` STRING[]), not a
691
+ Symbol->Symbol edge, so `_find_dependents` — which walks edges — never pulls
692
+ annotation users into scope. When an annotation definition changes (e.g.
693
+ ``@interface Foo`` gains a meta-annotation that shifts the Layer-A chain in
694
+ `resolve_role_and_capabilities`), every type carrying ``@Foo`` may need its
695
+ ``role``/``capabilities`` recomputed or it goes stale until the next full
696
+ rebuild. Return those users' files so the orchestrator treats them as
697
+ dependents (re-parsed, role re-SET); the expansion cap bounds the scope.
698
+
699
+ Scope is direct usage only: a user of an annotation that transitively
700
+ composes the changed one (e.g. ``@A`` where ``@A`` is meta-annotated with the
701
+ changed ``@B``) is NOT pulled in — that reverse-chain walk is left to a
702
+ future hardening pass. The direct case covers the dominant real-world shape
703
+ (a stereotype annotation applied directly to many types).
704
+ """
705
+ if not changed_node_ids:
706
+ return set()
707
+ # Changed annotation definitions → the simple names users reference them by.
708
+ # Runs before `_delete_file_scope`, so the def nodes still exist.
709
+ name_result = conn.execute(
710
+ "MATCH (s:Symbol) WHERE s.id IN $ids AND s.kind = 'annotation' RETURN s.name",
711
+ {"ids": list(changed_node_ids)},
712
+ )
713
+ names: list[str] = []
714
+ while name_result.has_next():
715
+ nm = name_result.get_next()[0]
716
+ if nm:
717
+ names.append(nm)
718
+ if not names:
719
+ return set()
720
+ dependent_files: set[str] = set()
721
+ for nm in names:
722
+ user_result = conn.execute(
723
+ "MATCH (s:Symbol) "
724
+ "WHERE list_contains(s.annotations, $nm) AND s.filename <> '' "
725
+ "RETURN DISTINCT s.filename",
726
+ {"nm": nm},
727
+ )
728
+ while user_result.has_next():
729
+ fn = user_result.get_next()[0]
730
+ if fn:
731
+ dependent_files.add(fn)
732
+ return dependent_files
733
+
734
+
673
735
  def _delete_file_scope(
674
736
  conn: ladybug.Connection,
675
737
  changed_files: set[str],
@@ -821,8 +883,8 @@ def _write_nodes_merge(
821
883
  project_root: Path,
822
884
  meta_chain: dict[str, frozenset[str]] | None,
823
885
  ) -> None:
824
- """Write nodes to existing LadybugDB database using MERGE to handle existing nodes."""
825
- _write_nodes_impl(conn, tables, project_root=project_root, meta_chain=meta_chain, symbol_query=_MERGE_SYMBOL)
886
+ """Write nodes to existing LadybugDB database using bulk COPY FROM."""
887
+ _write_nodes_impl(conn, tables, project_root=project_root, meta_chain=meta_chain)
826
888
 
827
889
 
828
890
  # ---------- file walk (see `path_filtering.iter_java_source_files`) ----------
@@ -3004,27 +3066,112 @@ def _node_row(**kwargs) -> dict:
3004
3066
  return base
3005
3067
 
3006
3068
 
3007
- _CREATE_SYMBOL = (
3008
- "CREATE (:Symbol {id: $id, kind: $kind, name: $name, fqn: $fqn, "
3009
- "package: $package, module: $module, microservice: $microservice, "
3010
- "filename: $filename, "
3011
- "start_line: $start_line, end_line: $end_line, "
3012
- "start_byte: $start_byte, end_byte: $end_byte, "
3013
- "modifiers: $modifiers, annotations: $annotations, capabilities: $capabilities, "
3014
- "role: $role, signature: $signature, parent_id: $parent_id, resolved: $resolved})"
3015
- )
3069
+ def _bulk_copy(conn: ladybug.Connection, table_name: str, columns: list[str], rows: list[dict]) -> None:
3070
+ """Bulk-load rows into a node/rel table via in-memory pyarrow COPY FROM.
3071
+
3072
+ `columns` fixes column order; for REL tables the first two MUST be the
3073
+ FROM/TO node primary keys (kuzu requirement). Empty `rows` is a no-op.
3074
+
3075
+ Spike result (PR-P1 step-1): REL `COPY FROM` expects columns named `FROM` and `TO`
3076
+ for the endpoint node IDs, followed by property columns in the declared order.
3077
+ `pa.Table.from_pylist(rows)` correctly infers types from the dict values, but
3078
+ we must select columns in the exact order expected by the table schema.
3079
+ """
3080
+ if not rows:
3081
+ return
3082
+ tbl = pa.Table.from_pylist(rows)
3083
+ # Select columns in the exact order expected by the table schema
3084
+ tbl = tbl.select(columns)
3085
+ conn.execute(f"COPY {table_name} FROM $rows", {"rows": tbl})
3016
3086
 
3017
- _MERGE_SYMBOL = (
3018
- "MERGE (n:Symbol {id: $id}) "
3087
+
3088
+ def _existing_node_ids(conn: ladybug.Connection) -> set[str]:
3089
+ """Return every node id (Symbol, Route, Client, Producer) currently in the graph.
3090
+
3091
+ Bulk ``COPY FROM`` enforces referential integrity: a REL row whose FROM/TO
3092
+ endpoint isn't a loaded node raises ``Unable to find primary key value``. The
3093
+ legacy per-row ``MERGE (a:Symbol {id:$src}),(b:Symbol {id:$dst})`` silently
3094
+ dropped such edges (a ``MATCH`` against a missing endpoint creates nothing).
3095
+ Edge writers filter edge rows against this set to reproduce that exactly.
3096
+
3097
+ This queries the live DB rather than just ``tables`` because it is shared
3098
+ with the incremental path, whose edges legitimately reference nodes written
3099
+ in prior runs.
3100
+ """
3101
+ result = conn.execute("MATCH (n) RETURN n.id")
3102
+ ids: set[str] = set()
3103
+ while result.has_next():
3104
+ ids.add(result.get_next()[0])
3105
+ return ids
3106
+
3107
+
3108
+ # Column-order constants for bulk COPY FROM.
3109
+ # For REL tables, the first two entries are FROM/TO node primary keys (kuzu requirement).
3110
+ # Order matches the corresponding _SCHEMA_* declarations above.
3111
+ _NODE_COLUMNS = [
3112
+ "id", "kind", "name", "fqn", "package", "module", "microservice",
3113
+ "filename", "start_line", "end_line", "start_byte", "end_byte",
3114
+ "modifiers", "annotations", "capabilities", "role", "signature", "parent_id", "resolved"
3115
+ ]
3116
+
3117
+ # Type declaration kinds. Tuple (not set) so the rendered SQL `IN` clause is
3118
+ # deterministic. Used to (a) load type stubs for cross-file resolution and
3119
+ # (b) scope the incremental property-refresh SET to type nodes.
3120
+ _TYPE_KINDS: tuple[str, ...] = ("class", "interface", "enum", "annotation", "record")
3121
+
3122
+ # Update every mutable Symbol field on an existing node by primary key. Used on
3123
+ # the incremental path to refresh preserved dependent type nodes whose
3124
+ # `role`/`capabilities` (and other project-wide-derived fields) can shift
3125
+ # without their own source changing — restoring the upsert the legacy per-row
3126
+ # `MERGE (n:Symbol {id:$id}) SET …` provided. Field list mirrors `_NODE_COLUMNS`
3127
+ # minus `id`.
3128
+ _SET_SYMBOL_BY_ID = (
3129
+ "MATCH (n:Symbol {id: $id}) "
3019
3130
  "SET n.kind = $kind, n.name = $name, n.fqn = $fqn, "
3020
3131
  "n.package = $package, n.module = $module, n.microservice = $microservice, "
3021
3132
  "n.filename = $filename, "
3022
3133
  "n.start_line = $start_line, n.end_line = $end_line, "
3023
3134
  "n.start_byte = $start_byte, n.end_byte = $end_byte, "
3024
- "n.modifiers = $modifiers, n.annotations = $annotations, n.capabilities = $capabilities, "
3025
- "n.role = $role, n.signature = $signature, n.parent_id = $parent_id, n.resolved = $resolved"
3135
+ "n.modifiers = $modifiers, n.annotations = $annotations, "
3136
+ "n.capabilities = $capabilities, n.role = $role, "
3137
+ "n.signature = $signature, n.parent_id = $parent_id, n.resolved = $resolved"
3138
+ )
3139
+
3140
+ # Refresh every mutable Route field on an existing Route node by id. Mirrors the
3141
+ # `_write_nodes_impl` Symbol pattern (bulk COPY new rows + per-row SET existing
3142
+ # ones) so the global pass 5-6 step no longer needs a per-row MERGE upsert.
3143
+ # Field list mirrors `_ROUTE_COLUMNS` minus `id`.
3144
+ _SET_ROUTE_BY_ID = (
3145
+ "MATCH (r:Route {id: $id}) "
3146
+ "SET r.kind = $kind, r.framework = $framework, r.method = $method, "
3147
+ "r.path = $path, r.path_template = $path_template, r.path_regex = $path_regex, "
3148
+ "r.topic = $topic, r.broker = $broker, r.feign_name = $feign_name, r.feign_url = $feign_url, "
3149
+ "r.microservice = $microservice, r.module = $module, r.filename = $filename, "
3150
+ "r.start_line = $start_line, r.end_line = $end_line, r.resolved = $resolved"
3026
3151
  )
3027
3152
 
3153
+ _REL_EXTENDS_COLUMNS = ["FROM", "TO", "source_file", "dst_name", "dst_fqn", "resolved"]
3154
+ _REL_IMPLEMENTS_COLUMNS = ["FROM", "TO", "source_file", "dst_name", "dst_fqn", "resolved"]
3155
+ _REL_INJECTS_COLUMNS = ["FROM", "TO", "source_file", "dst_name", "dst_fqn", "resolved", "mechanism", "annotation", "field_or_param"]
3156
+ _REL_DECLARES_COLUMNS = ["FROM", "TO", "source_file"]
3157
+ _REL_OVERRIDES_COLUMNS = ["FROM", "TO", "source_file"]
3158
+ _REL_CALLS_COLUMNS = ["FROM", "TO", "source_file", "call_site_line", "call_site_byte", "arg_count", "confidence", "strategy", "source", "resolved", "callee_declaring_role"]
3159
+
3160
+ _UNRESOLVED_CALL_SITE_COLUMNS = ["id", "caller_id", "call_site_line", "call_site_byte", "arg_count", "callee_simple", "receiver_expr", "reason"]
3161
+ _REL_UNRESOLVED_AT_COLUMNS = ["FROM", "TO", "source_file"]
3162
+
3163
+ # Node table column constants (for bulk COPY FROM)
3164
+ _ROUTE_COLUMNS = ["id", "kind", "framework", "method", "path", "path_template", "path_regex", "topic", "broker", "feign_name", "feign_url", "microservice", "module", "filename", "start_line", "end_line", "resolved"]
3165
+ _CLIENT_COLUMNS = ["id", "client_kind", "target_service", "path", "path_template", "path_regex", "method", "member_fqn", "member_id", "microservice", "module", "filename", "start_line", "end_line", "resolved", "source_layer"]
3166
+ _PRODUCER_COLUMNS = ["id", "producer_kind", "topic", "broker", "direction", "member_fqn", "member_id", "microservice", "module", "filename", "start_line", "end_line", "resolved", "source_layer"]
3167
+
3168
+ # REL table column constants for routes/clients/producers
3169
+ _REL_EXPOSES_COLUMNS = ["FROM", "TO", "source_file", "confidence", "strategy"]
3170
+ _REL_DECLARES_CLIENT_COLUMNS = ["FROM", "TO", "source_file", "confidence", "strategy"]
3171
+ _REL_DECLARES_PRODUCER_COLUMNS = ["FROM", "TO", "source_file", "confidence", "strategy"]
3172
+ _REL_HTTP_CALLS_COLUMNS = ["FROM", "TO", "source_file", "confidence", "strategy", "method_call", "raw_uri", "match"]
3173
+ _REL_ASYNC_CALLS_COLUMNS = ["FROM", "TO", "source_file", "confidence", "strategy", "direction", "raw_topic", "match"]
3174
+
3028
3175
 
3029
3176
  def _write_nodes_impl(
3030
3177
  conn: ladybug.Connection,
@@ -3032,7 +3179,6 @@ def _write_nodes_impl(
3032
3179
  *,
3033
3180
  project_root: Path,
3034
3181
  meta_chain: dict[str, frozenset[str]] | None,
3035
- symbol_query: str,
3036
3182
  ) -> None:
3037
3183
  overrides = load_brownfield_overrides(project_root)
3038
3184
  try:
@@ -3041,18 +3187,28 @@ def _write_nodes_impl(
3041
3187
  prs = str(project_root)
3042
3188
  tables.cross_service_resolution = _load_config_cross_service_resolution(prs)
3043
3189
  mch = meta_chain
3190
+
3191
+ # Stage all Symbol rows
3192
+ rows: list[dict] = []
3193
+ # Node ids loaded from the existing graph as resolution-only stubs
3194
+ # (`_load_existing_types`); their staged rows carry placeholder values and
3195
+ # must never be written back over the real nodes.
3196
+ stub_ids: set[str] = set()
3197
+
3044
3198
  # packages
3045
3199
  for pkg, pid in tables.packages.items():
3046
- conn.execute(symbol_query, _node_row(
3200
+ rows.append(_node_row(
3047
3201
  id=pid, kind="package", name=pkg.rsplit(".", 1)[-1], fqn=pkg, package=pkg,
3048
3202
  ))
3049
3203
  # files
3050
3204
  for path, fid in tables.files.items():
3051
- conn.execute(symbol_query, _node_row(
3205
+ rows.append(_node_row(
3052
3206
  id=fid, kind="file", name=Path(path).name, fqn=path, filename=path,
3053
3207
  ))
3054
3208
  # types
3055
3209
  for entry in tables.types.values():
3210
+ if entry.loaded_from_db:
3211
+ stub_ids.add(entry.node_id)
3056
3212
  d = entry.decl
3057
3213
  role, capabilities = resolve_role_and_capabilities(
3058
3214
  d,
@@ -3060,7 +3216,7 @@ def _write_nodes_impl(
3060
3216
  meta_chain=mch,
3061
3217
  )
3062
3218
  tables.type_role_by_node_id[entry.node_id] = role
3063
- conn.execute(symbol_query, _node_row(
3219
+ rows.append(_node_row(
3064
3220
  id=entry.node_id, kind=d.kind, name=d.name, fqn=d.fqn,
3065
3221
  package=entry.package,
3066
3222
  module=entry.module, microservice=entry.microservice,
@@ -3076,7 +3232,7 @@ def _write_nodes_impl(
3076
3232
  ))
3077
3233
  # members (methods / constructors)
3078
3234
  for m in tables.members:
3079
- conn.execute(symbol_query, _node_row(
3235
+ rows.append(_node_row(
3080
3236
  id=m.node_id, kind=m.kind, name=m.decl.name,
3081
3237
  fqn=f"{m.parent_fqn}#{m.decl.signature}",
3082
3238
  package=tables.types[m.parent_fqn].package if m.parent_fqn in tables.types else "",
@@ -3090,7 +3246,35 @@ def _write_nodes_impl(
3090
3246
  ))
3091
3247
  # phantoms
3092
3248
  for pid, row in tables.phantoms.items():
3093
- conn.execute(symbol_query, row)
3249
+ rows.append(row)
3250
+
3251
+ # Bulk-load new Symbol rows. The full-rebuild path starts from an empty
3252
+ # database (`_drop_all`), so every row is new. The incremental path reaches
3253
+ # here with a populated database: changed-file nodes were deleted by
3254
+ # `_delete_file_scope` (absent here → new), while dependent-file nodes are
3255
+ # deliberately preserved (see `_delete_file_scope` / issue #305).
3256
+ existing_ids = _existing_node_ids(conn)
3257
+ new_rows = [row for row in rows if row["id"] not in existing_ids]
3258
+ _bulk_copy(conn, "Symbol", _NODE_COLUMNS, new_rows)
3259
+
3260
+ # Refresh mutable properties on preserved dependent TYPE nodes (incremental
3261
+ # path only; `update_rows` is empty on the full path). `role`/`capabilities`
3262
+ # — and any other field derived from project-wide inputs (meta-annotation
3263
+ # chain, brownfield overrides) — can shift without the type's own source
3264
+ # changing, so a preserved dependent must be re-SET to stay byte-equivalent
3265
+ # with a full rebuild. The legacy per-row `_MERGE_SYMBOL` upserted every
3266
+ # staged node and did this implicitly; bulk `COPY FROM` only appends, so the
3267
+ # SET is explicit here. Stubs (`stub_ids`) are skipped: their decl is a
3268
+ # placeholder and their stored values are authoritative. Non-type kinds
3269
+ # carry no mutable role/capabilities, so they are skipped too.
3270
+ update_rows = [
3271
+ row for row in rows
3272
+ if row["id"] in existing_ids
3273
+ and row["id"] not in stub_ids
3274
+ and row["kind"] in _TYPE_KINDS
3275
+ ]
3276
+ for row in update_rows:
3277
+ conn.execute(_SET_SYMBOL_BY_ID, row)
3094
3278
 
3095
3279
 
3096
3280
  def _write_nodes(
@@ -3100,95 +3284,21 @@ def _write_nodes(
3100
3284
  project_root: Path,
3101
3285
  meta_chain: dict[str, frozenset[str]] | None,
3102
3286
  ) -> None:
3103
- _write_nodes_impl(conn, tables, project_root=project_root, meta_chain=meta_chain, symbol_query=_CREATE_SYMBOL)
3287
+ _write_nodes_impl(conn, tables, project_root=project_root, meta_chain=meta_chain)
3104
3288
 
3105
3289
 
3106
- _CREATE_EXT = (
3107
- "MATCH (a:Symbol {id: $src}), (b:Symbol {id: $dst}) "
3108
- "CREATE (a)-[:EXTENDS {source_file: $source_file, dst_name: $dst_name, dst_fqn: $dst_fqn, resolved: $resolved}]->(b)"
3109
- )
3110
- _CREATE_IMPL = (
3111
- "MATCH (a:Symbol {id: $src}), (b:Symbol {id: $dst}) "
3112
- "CREATE (a)-[:IMPLEMENTS {source_file: $source_file, dst_name: $dst_name, dst_fqn: $dst_fqn, resolved: $resolved}]->(b)"
3113
- )
3114
- _CREATE_INJ = (
3115
- "MATCH (a:Symbol {id: $src}), (b:Symbol {id: $dst}) "
3116
- "CREATE (a)-[:INJECTS {source_file: $source_file, dst_name: $dst_name, dst_fqn: $dst_fqn, resolved: $resolved, "
3117
- "mechanism: $mechanism, annotation: $annotation, field_or_param: $field_or_param}]->(b)"
3118
- )
3119
- _CREATE_DECL = (
3120
- "MATCH (a:Symbol {id: $src}), (b:Symbol {id: $dst}) "
3121
- "CREATE (a)-[:DECLARES {source_file: $source_file}]->(b)"
3122
- )
3123
- _CREATE_OVERRIDES = (
3124
- "MATCH (a:Symbol {id: $src}), (b:Symbol {id: $dst}) "
3125
- "CREATE (a)-[:OVERRIDES {source_file: $source_file}]->(b)"
3126
- )
3127
- _CREATE_CALL = (
3128
- "MATCH (a:Symbol {id: $src}), (b:Symbol {id: $dst}) "
3129
- "CREATE (a)-[:CALLS {"
3130
- "source_file: $source_file, "
3131
- "call_site_line: $line, call_site_byte: $byte, arg_count: $argc, "
3132
- "confidence: $conf, strategy: $strat, source: $src_kind, resolved: $resolved, "
3133
- "callee_declaring_role: $callee_declaring_role"
3134
- "}]->(b)"
3135
- )
3136
-
3137
- _CREATE_ROUTE = (
3138
- "CREATE (:Route {"
3139
- "id: $id, kind: $kind, framework: $framework, method: $method, "
3140
- "path: $path, path_template: $path_template, path_regex: $path_regex, "
3141
- "topic: $topic, broker: $broker, feign_name: $feign_name, feign_url: $feign_url, "
3142
- "microservice: $microservice, module: $module, filename: $filename, "
3143
- "start_line: $start_line, end_line: $end_line, resolved: $resolved"
3144
- "})"
3145
- )
3146
- _CREATE_CLIENT = (
3147
- "CREATE (:Client {"
3148
- "id: $id, client_kind: $client_kind, target_service: $target_service, "
3149
- "path: $path, path_template: $path_template, path_regex: $path_regex, method: $method, "
3150
- "member_fqn: $member_fqn, member_id: $member_id, "
3151
- "microservice: $microservice, module: $module, filename: $filename, "
3152
- "start_line: $start_line, end_line: $end_line, resolved: $resolved, source_layer: $source_layer"
3153
- "})"
3154
- )
3155
-
3156
- _CREATE_EXPOSES = (
3157
- "MATCH (s:Symbol {id: $sid}), (r:Route {id: $rid}) "
3158
- "CREATE (s)-[:EXPOSES {source_file: $source_file, confidence: $confidence, strategy: $strategy}]->(r)"
3159
- )
3160
- _CREATE_DECLARES_CLIENT = (
3161
- "MATCH (s:Symbol {id: $sid}), (c:Client {id: $cid}) "
3162
- "CREATE (s)-[:DECLARES_CLIENT {source_file: $source_file, confidence: $confidence, strategy: $strategy}]->(c)"
3163
- )
3164
- _CREATE_PRODUCER = (
3165
- "CREATE (:Producer {"
3166
- "id: $id, producer_kind: $producer_kind, topic: $topic, broker: $broker, "
3167
- "direction: $direction, member_fqn: $member_fqn, member_id: $member_id, "
3168
- "microservice: $microservice, module: $module, filename: $filename, "
3169
- "start_line: $start_line, end_line: $end_line, resolved: $resolved, "
3170
- "source_layer: $source_layer"
3171
- "})"
3172
- )
3173
- _CREATE_DECLARES_PRODUCER = (
3174
- "MATCH (s:Symbol {id: $sid}), (p:Producer {id: $pid}) "
3175
- "CREATE (s)-[:DECLARES_PRODUCER {source_file: $source_file, confidence: $confidence, strategy: $strategy}]->(p)"
3176
- )
3177
- _CREATE_HTTP_CALL = (
3178
- "MATCH (c:Client {id: $cid}), (r:Route {id: $rid}) "
3179
- "CREATE (c)-[:HTTP_CALLS {source_file: $source_file, confidence: $confidence, strategy: $strategy, "
3180
- "method_call: $method_call, raw_uri: $raw_uri, match: $match}]->(r)"
3181
- )
3182
- _CREATE_ASYNC_CALL = (
3183
- "MATCH (p:Producer {id: $pid}), (r:Route {id: $rid}) "
3184
- "CREATE (p)-[:ASYNC_CALLS {source_file: $source_file, confidence: $confidence, strategy: $strategy, "
3185
- "direction: $direction, raw_topic: $raw_topic, match: $match}]->(r)"
3186
- )
3187
3290
 
3188
3291
 
3189
3292
  def _populate_declares_rows(tables: GraphTables) -> None:
3293
+ # Skip members loaded from the existing graph for cross-file resolution: a
3294
+ # DECLARES edge for an unchanged-file member already persists (its
3295
+ # source_file is out of scope, so `_delete_file_scope` left it), and
3296
+ # re-emitting it would append a duplicate (REL tables carry no primary key).
3297
+ # Full-rebuild never loads members, so this is a no-op there.
3190
3298
  tables.declares_rows = [
3191
- DeclaresRow(src_id=m.parent_id, dst_id=m.node_id) for m in tables.members
3299
+ DeclaresRow(src_id=m.parent_id, dst_id=m.node_id)
3300
+ for m in tables.members
3301
+ if not m.loaded_from_db
3192
3302
  ]
3193
3303
 
3194
3304
 
@@ -3246,93 +3356,126 @@ def _write_edges(conn: ladybug.Connection, tables: GraphTables, _file_by_node_id
3246
3356
  if _file_by_node_id is None:
3247
3357
  _file_by_node_id = _build_file_by_node_id(tables)
3248
3358
 
3249
- for r in tables.extends_rows:
3250
- conn.execute(_CREATE_EXT, {
3251
- "src": r.src_id, "dst": r.dst_id,
3359
+ # Bulk COPY FROM enforces referential integrity — a REL row whose endpoint
3360
+ # node isn't loaded raises "Unable to find primary key value". The legacy
3361
+ # per-row MERGE silently skipped such edges; drop them here to preserve the
3362
+ # per-row graph exactly. _existing_node_ids reads the live DB (not just
3363
+ # `tables`) so the incremental path's references to prior-run nodes still hold.
3364
+ valid_ids = _existing_node_ids(conn)
3365
+
3366
+ # Stage EXTENDS rows
3367
+ extends_rows = [
3368
+ {
3369
+ "FROM": r.src_id, "TO": r.dst_id,
3252
3370
  "source_file": _file_by_node_id.get(r.src_id, ""),
3253
3371
  "dst_name": r.dst_name, "dst_fqn": r.dst_fqn, "resolved": r.resolved,
3254
- })
3255
- for r in tables.implements_rows:
3256
- conn.execute(_CREATE_IMPL, {
3257
- "src": r.src_id, "dst": r.dst_id,
3372
+ }
3373
+ for r in tables.extends_rows
3374
+ if r.src_id in valid_ids and r.dst_id in valid_ids
3375
+ ]
3376
+ _bulk_copy(conn, "EXTENDS", _REL_EXTENDS_COLUMNS, extends_rows)
3377
+
3378
+ # Stage IMPLEMENTS rows
3379
+ implements_rows = [
3380
+ {
3381
+ "FROM": r.src_id, "TO": r.dst_id,
3258
3382
  "source_file": _file_by_node_id.get(r.src_id, ""),
3259
3383
  "dst_name": r.dst_name, "dst_fqn": r.dst_fqn, "resolved": r.resolved,
3260
- })
3261
- for r in tables.injects_rows:
3262
- conn.execute(_CREATE_INJ, {
3263
- "src": r.src_id, "dst": r.dst_id,
3384
+ }
3385
+ for r in tables.implements_rows
3386
+ if r.src_id in valid_ids and r.dst_id in valid_ids
3387
+ ]
3388
+ _bulk_copy(conn, "IMPLEMENTS", _REL_IMPLEMENTS_COLUMNS, implements_rows)
3389
+
3390
+ # Stage INJECTS rows
3391
+ injects_rows = [
3392
+ {
3393
+ "FROM": r.src_id, "TO": r.dst_id,
3264
3394
  "source_file": _file_by_node_id.get(r.src_id, ""),
3265
3395
  "dst_name": r.dst_name, "dst_fqn": r.dst_fqn, "resolved": r.resolved,
3266
3396
  "mechanism": r.mechanism, "annotation": r.annotation,
3267
3397
  "field_or_param": r.field_or_param,
3268
- })
3398
+ }
3399
+ for r in tables.injects_rows
3400
+ if r.src_id in valid_ids and r.dst_id in valid_ids
3401
+ ]
3402
+ _bulk_copy(conn, "INJECTS", _REL_INJECTS_COLUMNS, injects_rows)
3269
3403
 
3270
- for row in tables.declares_rows:
3271
- conn.execute(_CREATE_DECL, {
3272
- "src": row.src_id, "dst": row.dst_id,
3404
+ # Stage DECLARES rows
3405
+ declares_rows = [
3406
+ {
3407
+ "FROM": row.src_id, "TO": row.dst_id,
3273
3408
  "source_file": _file_by_node_id.get(row.src_id, ""),
3274
- })
3409
+ }
3410
+ for row in tables.declares_rows
3411
+ if row.src_id in valid_ids and row.dst_id in valid_ids
3412
+ ]
3413
+ _bulk_copy(conn, "DECLARES", _REL_DECLARES_COLUMNS, declares_rows)
3275
3414
 
3276
- for row in tables.overrides_rows:
3277
- conn.execute(_CREATE_OVERRIDES, {
3278
- "src": row.src_id, "dst": row.dst_id,
3415
+ # Stage OVERRIDES rows
3416
+ overrides_rows = [
3417
+ {
3418
+ "FROM": row.src_id, "TO": row.dst_id,
3279
3419
  "source_file": _file_by_node_id.get(row.src_id, ""),
3280
- })
3420
+ }
3421
+ for row in tables.overrides_rows
3422
+ if row.src_id in valid_ids and row.dst_id in valid_ids
3423
+ ]
3424
+ _bulk_copy(conn, "OVERRIDES", _REL_OVERRIDES_COLUMNS, overrides_rows)
3281
3425
 
3426
+ # Stage CALLS rows with dedup and callee_declaring_role materialization
3282
3427
  seen_calls: set[tuple[str, str, int, int]] = set()
3283
- unique_calls: list[CallsRow] = []
3428
+ calls_rows: list[dict] = []
3429
+ member_by_id = {m.node_id: m for m in tables.members}
3284
3430
  for row in tables.calls_rows:
3431
+ if row.src_id not in valid_ids or row.dst_id not in valid_ids:
3432
+ continue
3285
3433
  key = (row.src_id, row.dst_id, row.arg_count, row.call_site_line)
3286
- if key not in seen_calls:
3287
- seen_calls.add(key)
3288
- unique_calls.append(row)
3289
-
3290
- member_by_id = {m.node_id: m for m in tables.members}
3291
- for row in unique_calls:
3292
- conn.execute(_CREATE_CALL, {
3293
- "src": row.src_id, "dst": row.dst_id,
3434
+ if key in seen_calls:
3435
+ continue
3436
+ seen_calls.add(key)
3437
+ calls_rows.append({
3438
+ "FROM": row.src_id, "TO": row.dst_id,
3294
3439
  "source_file": _file_by_node_id.get(row.src_id, ""),
3295
- "line": row.call_site_line,
3296
- "byte": row.call_site_byte,
3297
- "argc": row.arg_count,
3298
- "conf": row.confidence,
3299
- "strat": row.strategy,
3300
- "src_kind": row.source,
3301
- "resolved": row.resolved,
3440
+ "call_site_line": row.call_site_line, "call_site_byte": row.call_site_byte,
3441
+ "arg_count": row.arg_count, "confidence": row.confidence, "strategy": row.strategy,
3442
+ "source": row.source, "resolved": row.resolved,
3302
3443
  "callee_declaring_role": _callee_declaring_role_at_write(
3303
3444
  tables, row.dst_id, member_by_id=member_by_id,
3304
3445
  ),
3305
3446
  })
3447
+ _bulk_copy(conn, "CALLS", _REL_CALLS_COLUMNS, calls_rows)
3306
3448
 
3307
- _CREATE_UNRESOLVED = (
3308
- "CREATE (:UnresolvedCallSite {"
3309
- "id: $id, caller_id: $caller_id, call_site_line: $line, call_site_byte: $byte, "
3310
- "arg_count: $argc, callee_simple: $callee, receiver_expr: $recv, reason: $reason"
3311
- "})"
3312
- )
3313
- _CREATE_UNRESOLVED_AT = (
3314
- "MATCH (a:Symbol {id: $caller}), (u:UnresolvedCallSite {id: $ucs}) "
3315
- "CREATE (a)-[:UNRESOLVED_AT {source_file: $source_file}]->(u)"
3316
- )
3449
+ # Stage UnresolvedCallSite node rows (must load before UNRESOLVED_AT edges)
3317
3450
  seen_ucs: set[str] = set()
3451
+ ucs_rows: list[dict] = []
3318
3452
  for row in tables.unresolved_call_site_rows:
3319
3453
  if row.id in seen_ucs:
3320
3454
  continue
3321
3455
  seen_ucs.add(row.id)
3322
- conn.execute(_CREATE_UNRESOLVED, {
3456
+ ucs_rows.append({
3323
3457
  "id": row.id,
3324
3458
  "caller_id": row.caller_id,
3325
- "line": row.call_site_line,
3326
- "byte": row.call_site_byte,
3327
- "argc": row.arg_count,
3328
- "callee": row.callee_simple,
3329
- "recv": row.receiver_expr,
3459
+ "call_site_line": row.call_site_line,
3460
+ "call_site_byte": row.call_site_byte,
3461
+ "arg_count": row.arg_count,
3462
+ "callee_simple": row.callee_simple,
3463
+ "receiver_expr": row.receiver_expr,
3330
3464
  "reason": row.reason,
3331
3465
  })
3332
- conn.execute(_CREATE_UNRESOLVED_AT, {
3333
- "caller": row.caller_id, "ucs": row.id,
3334
- "source_file": _file_by_node_id.get(row.caller_id, ""),
3335
- })
3466
+ _bulk_copy(conn, "UnresolvedCallSite", _UNRESOLVED_CALL_SITE_COLUMNS, ucs_rows)
3467
+
3468
+ # Stage UNRESOLVED_AT edge rows (one per unique UnresolvedCallSite node)
3469
+ # Use the same ucs_rows list to ensure 1:1 correspondence
3470
+ unresolved_at_rows = [
3471
+ {
3472
+ "FROM": ucs_row["caller_id"], "TO": ucs_row["id"],
3473
+ "source_file": _file_by_node_id.get(ucs_row["caller_id"], ""),
3474
+ }
3475
+ for ucs_row in ucs_rows
3476
+ if ucs_row["caller_id"] in valid_ids
3477
+ ]
3478
+ _bulk_copy(conn, "UNRESOLVED_AT", _REL_UNRESOLVED_AT_COLUMNS, unresolved_at_rows)
3336
3479
 
3337
3480
 
3338
3481
  def _write_routes_and_exposes(conn: ladybug.Connection, tables: GraphTables, _file_by_node_id: dict[str, str] | None = None) -> None:
@@ -3346,8 +3489,12 @@ def _write_routes_and_exposes(conn: ladybug.Connection, tables: GraphTables, _fi
3346
3489
  # Build producer_id -> filename lookup for ASYNC_CALLS source_file.
3347
3490
  _file_by_producer_id: dict[str, str] = {row.id: row.filename for row in tables.producer_rows}
3348
3491
 
3349
- for row in tables.routes_rows:
3350
- conn.execute(_CREATE_ROUTE, {
3492
+ # Bulk COPY FROM enforces referential integrity — get all valid node IDs
3493
+ valid_ids = _existing_node_ids(conn)
3494
+
3495
+ # Stage Route node rows (bulk-load before edges that reference them)
3496
+ route_rows = [
3497
+ {
3351
3498
  "id": row.id,
3352
3499
  "kind": row.kind,
3353
3500
  "framework": row.framework,
@@ -3365,57 +3512,97 @@ def _write_routes_and_exposes(conn: ladybug.Connection, tables: GraphTables, _fi
3365
3512
  "start_line": row.start_line,
3366
3513
  "end_line": row.end_line,
3367
3514
  "resolved": row.resolved,
3368
- })
3369
- for row in tables.exposes_rows:
3370
- conn.execute(_CREATE_EXPOSES, {
3371
- "sid": row.symbol_id,
3372
- "rid": row.route_id,
3515
+ }
3516
+ for row in tables.routes_rows
3517
+ ]
3518
+ _bulk_copy(conn, "Route", _ROUTE_COLUMNS, route_rows)
3519
+
3520
+ # Stage Client node rows (bulk-load before edges that reference them)
3521
+ client_rows = [asdict(row) for row in tables.client_rows]
3522
+ _bulk_copy(conn, "Client", _CLIENT_COLUMNS, client_rows)
3523
+
3524
+ # Stage Producer node rows (bulk-load before edges that reference them)
3525
+ producer_rows = [asdict(row) for row in tables.producer_rows]
3526
+ _bulk_copy(conn, "Producer", _PRODUCER_COLUMNS, producer_rows)
3527
+
3528
+ # Re-fetch valid IDs after loading Route/Client/Producer nodes (now includes them)
3529
+ valid_ids = _existing_node_ids(conn)
3530
+
3531
+ # Stage EXPOSES edge rows (Symbol -> Route)
3532
+ exposes_rows = [
3533
+ {
3534
+ "FROM": row.symbol_id,
3535
+ "TO": row.route_id,
3373
3536
  "source_file": _file_by_node_id.get(row.symbol_id, ""),
3374
3537
  "confidence": row.confidence,
3375
3538
  "strategy": row.strategy,
3376
- })
3377
- for row in tables.client_rows:
3378
- conn.execute(_CREATE_CLIENT, asdict(row))
3379
- for row in tables.declares_client_rows:
3380
- conn.execute(_CREATE_DECLARES_CLIENT, {
3381
- "sid": row.symbol_id,
3382
- "cid": row.client_id,
3539
+ }
3540
+ for row in tables.exposes_rows
3541
+ if row.symbol_id in valid_ids and row.route_id in valid_ids
3542
+ ]
3543
+ _bulk_copy(conn, "EXPOSES", _REL_EXPOSES_COLUMNS, exposes_rows)
3544
+
3545
+ # Stage DECLARES_CLIENT edge rows (Symbol -> Client)
3546
+ declares_client_rows = [
3547
+ {
3548
+ "FROM": row.symbol_id,
3549
+ "TO": row.client_id,
3383
3550
  "source_file": _file_by_node_id.get(row.symbol_id, ""),
3384
3551
  "confidence": row.confidence,
3385
3552
  "strategy": row.strategy,
3386
- })
3387
- for row in tables.producer_rows:
3388
- conn.execute(_CREATE_PRODUCER, asdict(row))
3389
- for row in tables.declares_producer_rows:
3390
- conn.execute(_CREATE_DECLARES_PRODUCER, {
3391
- "sid": row.symbol_id,
3392
- "pid": row.producer_id,
3553
+ }
3554
+ for row in tables.declares_client_rows
3555
+ if row.symbol_id in valid_ids and row.client_id in valid_ids
3556
+ ]
3557
+ _bulk_copy(conn, "DECLARES_CLIENT", _REL_DECLARES_CLIENT_COLUMNS, declares_client_rows)
3558
+
3559
+ # Stage DECLARES_PRODUCER edge rows (Symbol -> Producer)
3560
+ declares_producer_rows = [
3561
+ {
3562
+ "FROM": row.symbol_id,
3563
+ "TO": row.producer_id,
3393
3564
  "source_file": _file_by_node_id.get(row.symbol_id, ""),
3394
3565
  "confidence": row.confidence,
3395
3566
  "strategy": row.strategy,
3396
- })
3397
- for row in tables.http_call_rows:
3398
- conn.execute(_CREATE_HTTP_CALL, {
3399
- "cid": row.client_id,
3400
- "rid": row.route_id,
3567
+ }
3568
+ for row in tables.declares_producer_rows
3569
+ if row.symbol_id in valid_ids and row.producer_id in valid_ids
3570
+ ]
3571
+ _bulk_copy(conn, "DECLARES_PRODUCER", _REL_DECLARES_PRODUCER_COLUMNS, declares_producer_rows)
3572
+
3573
+ # Stage HTTP_CALLS edge rows (Client -> Route)
3574
+ http_call_rows = [
3575
+ {
3576
+ "FROM": row.client_id,
3577
+ "TO": row.route_id,
3401
3578
  "source_file": _file_by_client_id.get(row.client_id, ""),
3402
3579
  "confidence": row.confidence,
3403
3580
  "strategy": row.strategy,
3404
3581
  "method_call": row.method_call,
3405
3582
  "raw_uri": row.raw_uri,
3406
3583
  "match": row.match,
3407
- })
3408
- for row in tables.async_call_rows:
3409
- conn.execute(_CREATE_ASYNC_CALL, {
3410
- "pid": row.producer_id,
3411
- "rid": row.route_id,
3584
+ }
3585
+ for row in tables.http_call_rows
3586
+ if row.client_id in valid_ids and row.route_id in valid_ids
3587
+ ]
3588
+ _bulk_copy(conn, "HTTP_CALLS", _REL_HTTP_CALLS_COLUMNS, http_call_rows)
3589
+
3590
+ # Stage ASYNC_CALLS edge rows (Producer -> Route)
3591
+ async_call_rows = [
3592
+ {
3593
+ "FROM": row.producer_id,
3594
+ "TO": row.route_id,
3412
3595
  "source_file": _file_by_producer_id.get(row.producer_id, ""),
3413
3596
  "confidence": row.confidence,
3414
3597
  "strategy": row.strategy,
3415
3598
  "direction": row.direction,
3416
3599
  "raw_topic": row.raw_topic,
3417
3600
  "match": row.match,
3418
- })
3601
+ }
3602
+ for row in tables.async_call_rows
3603
+ if row.producer_id in valid_ids and row.route_id in valid_ids
3604
+ ]
3605
+ _bulk_copy(conn, "ASYNC_CALLS", _REL_ASYNC_CALLS_COLUMNS, async_call_rows)
3419
3606
 
3420
3607
 
3421
3608
  def _write_meta(conn: ladybug.Connection, tables: GraphTables, source_root: Path) -> None:
@@ -3650,6 +3837,12 @@ def incremental_rebuild(
3650
3837
  # Find dependents
3651
3838
  dependent_files = _find_dependents(conn, changed_node_ids)
3652
3839
 
3840
+ # Annotation-definition change: also pull in files that USE the changed
3841
+ # annotation. Annotation usage is a node property, not a Symbol->Symbol
3842
+ # edge, so `_find_dependents` misses them and their role (derived from
3843
+ # the project-wide meta-chain) would go stale. See PR-P5b.
3844
+ dependent_files |= _find_annotation_dependents(conn, changed_node_ids)
3845
+
3653
3846
  # Union changed files with dependents
3654
3847
  scope_files = changed_files | dependent_files
3655
3848
 
@@ -3814,80 +4007,107 @@ def _write_clients_producers_and_calls(conn: ladybug.Connection, tables: GraphTa
3814
4007
  Route nodes (created by pass5 for cross-service calls) that wouldn't
3815
4008
  otherwise exist in LadybugDB.
3816
4009
  """
3817
- # Write phantom routes that don't already exist (pass5 creates these for cross-service calls)
3818
- for row in tables.routes_rows:
3819
- # MERGE to avoid duplicates with routes written during scoped step
3820
- conn.execute(
3821
- "MERGE (r:Route {id: $id}) "
3822
- "SET r.kind = $kind, r.framework = $framework, r.method = $method, "
3823
- "r.path = $path, r.path_template = $path_template, r.path_regex = $path_regex, "
3824
- "r.topic = $topic, r.broker = $broker, r.feign_name = $feign_name, r.feign_url = $feign_url, "
3825
- "r.microservice = $microservice, r.module = $module, r.filename = $filename, "
3826
- "r.start_line = $start_line, r.end_line = $end_line, r.resolved = $resolved",
3827
- asdict(row),
3828
- )
4010
+ # Bulk-write routes, mirroring `_write_nodes_impl`: COPY the rows that are new
4011
+ # to the DB, and SET every mutable field on the routes already present (the
4012
+ # caller does NOT delete routes, only Clients/Producers see the global
4013
+ # pass 5-6 block). `tables.routes_rows` is the full route set (pass4 routes +
4014
+ # pass5 phantom routes), not just phantoms, so the SET keeps existing routes'
4015
+ # properties in sync with pass5's cross-service enrichment while the COPY
4016
+ # materializes phantoms that have no node yet. Replaces the last per-row
4017
+ # graph write (MERGE upsert).
4018
+ route_rows = [asdict(row) for row in tables.routes_rows]
4019
+ existing_route_ids = _existing_node_ids(conn)
4020
+ new_route_rows = [r for r in route_rows if r["id"] not in existing_route_ids]
4021
+ _bulk_copy(conn, "Route", _ROUTE_COLUMNS, new_route_rows)
4022
+ for r in route_rows:
4023
+ if r["id"] in existing_route_ids:
4024
+ conn.execute(_SET_ROUTE_BY_ID, r)
3829
4025
 
3830
4026
  # Build node_id lookup for members and types
3831
4027
  member_by_id = {m.node_id: m for m in tables.members}
3832
4028
 
3833
- # Write clients and producers using asdict (same pattern as _write_routes_and_exposes)
3834
- for row in tables.client_rows:
3835
- conn.execute(_CREATE_CLIENT, asdict(row))
3836
- for row in tables.producer_rows:
3837
- conn.execute(_CREATE_PRODUCER, asdict(row))
3838
-
4029
+ # Build client_id and producer_id lookups for source_file resolution
3839
4030
  client_by_id = {c.id: c for c in tables.client_rows}
3840
4031
  producer_by_id = {p.id: p for p in tables.producer_rows}
3841
4032
 
3842
- # Write declares_client edges
3843
- for row in tables.declares_client_rows:
3844
- source_file = member_by_id.get(row.symbol_id, MemberEntry(kind="", decl=None, parent_id="", parent_fqn="", file_path="", module="", microservice="", node_id="")).file_path
3845
- conn.execute(_CREATE_DECLARES_CLIENT, {
3846
- "sid": row.symbol_id,
3847
- "cid": row.client_id,
3848
- "source_file": source_file,
4033
+ # Stage Client + Producer NODE rows unconditionally. The caller DETACH-DELETEs
4034
+ # every Client/Producer node immediately before calling this (see the global
4035
+ # pass 5-6 block), so none are in the DB yet and COPY-ing them fresh is safe.
4036
+ # Do NOT filter node rows against the existing-id set — that is the EDGE filter
4037
+ # pattern mis-applied to nodes, and would drop every node being created (the
4038
+ # caller's delete makes the pre-load set empty by construction).
4039
+ client_rows = [asdict(row) for row in tables.client_rows]
4040
+ _bulk_copy(conn, "Client", _CLIENT_COLUMNS, client_rows)
4041
+ producer_rows = [asdict(row) for row in tables.producer_rows]
4042
+ _bulk_copy(conn, "Producer", _PRODUCER_COLUMNS, producer_rows)
4043
+
4044
+ # Endpoint filtering applies to EDGES only: re-fetch the live node set (now
4045
+ # includes the Clients/Producers just loaded) and drop edge rows whose
4046
+ # endpoints still aren't materialized — reproduces per-row MERGE silent-drop.
4047
+ valid_ids = _existing_node_ids(conn)
4048
+
4049
+ # Stage DECLARES_CLIENT edge rows (Symbol -> Client)
4050
+ # Build source_file lookup from member_by_id
4051
+ declares_client_rows = [
4052
+ {
4053
+ "FROM": row.symbol_id,
4054
+ "TO": row.client_id,
4055
+ "source_file": member_by_id.get(row.symbol_id, MemberEntry(kind="", decl=None, parent_id="", parent_fqn="", file_path="", module="", microservice="", node_id="")).file_path,
3849
4056
  "confidence": row.confidence,
3850
4057
  "strategy": row.strategy,
3851
- })
4058
+ }
4059
+ for row in tables.declares_client_rows
4060
+ if row.symbol_id in valid_ids and row.client_id in valid_ids
4061
+ ]
4062
+ _bulk_copy(conn, "DECLARES_CLIENT", _REL_DECLARES_CLIENT_COLUMNS, declares_client_rows)
3852
4063
 
3853
- # Write declares_producer edges
3854
- for row in tables.declares_producer_rows:
3855
- source_file = member_by_id.get(row.symbol_id, MemberEntry(kind="", decl=None, parent_id="", parent_fqn="", file_path="", module="", microservice="", node_id="")).file_path
3856
- conn.execute(_CREATE_DECLARES_PRODUCER, {
3857
- "sid": row.symbol_id,
3858
- "pid": row.producer_id,
3859
- "source_file": source_file,
4064
+ # Stage DECLARES_PRODUCER edge rows (Symbol -> Producer)
4065
+ declares_producer_rows = [
4066
+ {
4067
+ "FROM": row.symbol_id,
4068
+ "TO": row.producer_id,
4069
+ "source_file": member_by_id.get(row.symbol_id, MemberEntry(kind="", decl=None, parent_id="", parent_fqn="", file_path="", module="", microservice="", node_id="")).file_path,
3860
4070
  "confidence": row.confidence,
3861
4071
  "strategy": row.strategy,
3862
- })
4072
+ }
4073
+ for row in tables.declares_producer_rows
4074
+ if row.symbol_id in valid_ids and row.producer_id in valid_ids
4075
+ ]
4076
+ _bulk_copy(conn, "DECLARES_PRODUCER", _REL_DECLARES_PRODUCER_COLUMNS, declares_producer_rows)
3863
4077
 
3864
- # Write HTTP_CALLS edges
3865
- for row in tables.http_call_rows:
3866
- client = client_by_id.get(row.client_id)
3867
- conn.execute(_CREATE_HTTP_CALL, {
3868
- "cid": row.client_id,
3869
- "rid": row.route_id,
3870
- "source_file": client.filename if client else "",
4078
+ # Stage HTTP_CALLS edge rows (Client -> Route)
4079
+ http_call_rows = [
4080
+ {
4081
+ "FROM": row.client_id,
4082
+ "TO": row.route_id,
4083
+ "source_file": client_by_id.get(row.client_id, ClientRow(id="", client_kind="", target_service="", path="", path_template="", path_regex="", method="", member_fqn="", member_id="", microservice="", module="", filename="", start_line=0, end_line=0, resolved=False, source_layer="")).filename,
3871
4084
  "confidence": row.confidence,
3872
4085
  "strategy": row.strategy,
3873
4086
  "method_call": row.method_call,
3874
4087
  "raw_uri": row.raw_uri,
3875
4088
  "match": row.match,
3876
- })
4089
+ }
4090
+ for row in tables.http_call_rows
4091
+ if row.client_id in valid_ids and row.route_id in valid_ids
4092
+ ]
4093
+ _bulk_copy(conn, "HTTP_CALLS", _REL_HTTP_CALLS_COLUMNS, http_call_rows)
3877
4094
 
3878
- # Write ASYNC_CALLS edges
3879
- for row in tables.async_call_rows:
3880
- producer = producer_by_id.get(row.producer_id)
3881
- conn.execute(_CREATE_ASYNC_CALL, {
3882
- "pid": row.producer_id,
3883
- "rid": row.route_id,
3884
- "source_file": producer.filename if producer else "",
4095
+ # Stage ASYNC_CALLS edge rows (Producer -> Route)
4096
+ async_call_rows = [
4097
+ {
4098
+ "FROM": row.producer_id,
4099
+ "TO": row.route_id,
4100
+ "source_file": producer_by_id.get(row.producer_id, ProducerRow(id="", producer_kind="", topic="", broker="", direction="", member_fqn="", member_id="", microservice="", module="", filename="", start_line=0, end_line=0, resolved=False, source_layer="")).filename,
3885
4101
  "confidence": row.confidence,
3886
4102
  "strategy": row.strategy,
3887
4103
  "direction": row.direction,
3888
4104
  "raw_topic": row.raw_topic,
3889
4105
  "match": row.match,
3890
- })
4106
+ }
4107
+ for row in tables.async_call_rows
4108
+ if row.producer_id in valid_ids and row.route_id in valid_ids
4109
+ ]
4110
+ _bulk_copy(conn, "ASYNC_CALLS", _REL_ASYNC_CALLS_COLUMNS, async_call_rows)
3891
4111
 
3892
4112
 
3893
4113
  def write_ladybug(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-codebase-rag
3
- Version: 0.6.4
3
+ Version: 0.6.5
4
4
  Summary: MCP server for semantic + structural search over Java codebases
5
5
  Author: HumanBean17
6
6
  License-Expression: MIT
@@ -1,16 +1,16 @@
1
1
  ast_java.py,sha256=Paee3ZV9G5iy8LqfkVq0Ah_1fV0k632oS5JPkiWzwB8,99206
2
2
  brownfield_events.py,sha256=yxXkKDgMb3VPtaiakGzncHM_EGnda8xIue6w90yYp8s,2055
3
- build_ast_graph.py,sha256=vXWUuNC_k6-yKv6XawlHCXiC_dTNs1RoSKdZTXYBxgA,157374
3
+ build_ast_graph.py,sha256=jubb9Ex_6B3ktsInqNkk-EksBtQjofoFhE92lmHr308,169659
4
4
  chunk_heuristics.py,sha256=aQk2NOKxzUdqoUAJUO3G3LE0MN_bYZWNLQ0tkmj5uts,1813
5
5
  graph_enrich.py,sha256=W_OQK7YRU1K8wi-vfrxZWUd-EErTqWx840UBrhVpNsM,62788
6
6
  index_common.py,sha256=HT6FKHFJ084eFvd3fR1j8z8gf4eWoPHVW8GXLpw464I,285
7
- java_index_flow_lancedb.py,sha256=HClxupO2uHpGw9GNVIDs24bjoCQzg3VLbOZoA0hE_Gc,23761
7
+ java_index_flow_lancedb.py,sha256=JHdRWDZoZ3wnxi8NNG2ck8zEmSALHB2UVB1N8TSvlmg,24109
8
8
  java_index_v1_common.py,sha256=nF1KrSqboF_RRvWerG9knRRFmWwsrG_CvhgnsoZ8KqA,1154
9
9
  java_ontology.py,sha256=71bCLDNvMy0SpZPzSR5apJ0qJXNd6y5ggkLdBEw_PFo,16682
10
10
  ladybug_queries.py,sha256=7vSP7WsUKQYXHFenBMlCdpUFed39h3oul-WRkf55YVc,90330
11
11
  mcp_hints.py,sha256=3swh05LSiWur3tm3-yssndBsLxIxFhy501kBtJI8jJ0,42509
12
12
  mcp_v2.py,sha256=S8PiVGDlyI7cvTmeMdQobhKUxWqKoY2YxXXr4pu9lQI,80348
13
- path_filtering.py,sha256=cN_znZRXIY1CfTIWlfiOrnNqafb2BZs00oDaqjTxmFE,17169
13
+ path_filtering.py,sha256=R--XzI51LXBu5IBKMCnJWbkNr6I5d-SDmltyQQnWco0,17674
14
14
  pr_analysis.py,sha256=zrmZZD5yotJtM02Kif6_jgI_oeformOao793akp0N6Y,18394
15
15
  search_lancedb.py,sha256=ga_qySeCzA6hCibxUPXpCbTxW9mCjTdwirMhMhfNCz4,36801
16
16
  server.py,sha256=DcJwocSknBy0vwsUvYiC5hr-hrD_rRT-u9_DmuRTC9k,35035
@@ -26,9 +26,9 @@ java_codebase_rag/pipeline.py,sha256=ydNktEGL1YniAjJsr37yKBo_bGV4cN_LTGVTmmrsrZw
26
26
  java_codebase_rag/progress.py,sha256=2IxdMALDM0wAQCyJrrfZ975zM_85C-4BfHxf4AtYifE,23212
27
27
  java_codebase_rag/install_data/agents/explorer-rag-enhanced.md,sha256=BkdQpBEWqSdvGHgbqMdRb5CWfEiFRJK4Dgqbyal3l6s,14551
28
28
  java_codebase_rag/install_data/skills/explore-codebase/SKILL.md,sha256=YkRnrM7Wh5E8raFjAW3RrN2V9-ov8upaGC3UdpSx6U8,12346
29
- java_codebase_rag-0.6.4.dist-info/licenses/LICENSE,sha256=gxvtiHtuviR_q8ZAjWw-QTcF3DyPzg6ZY-lQrr8OPpw,1068
30
- java_codebase_rag-0.6.4.dist-info/METADATA,sha256=FR0VQ3dS_OS2v-n_zxOqcE0pZsHRvhDAiOGLse0pAoI,17242
31
- java_codebase_rag-0.6.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
32
- java_codebase_rag-0.6.4.dist-info/entry_points.txt,sha256=wsPZwot0Ui4JI3TIgW8LcbN8bNtKFbwQAlHAAJXfYgQ,117
33
- java_codebase_rag-0.6.4.dist-info/top_level.txt,sha256=syQgi8XPBwY2ws_NZ1uRCxTf_s41NpshwEHNdcdnk3A,245
34
- java_codebase_rag-0.6.4.dist-info/RECORD,,
29
+ java_codebase_rag-0.6.5.dist-info/licenses/LICENSE,sha256=gxvtiHtuviR_q8ZAjWw-QTcF3DyPzg6ZY-lQrr8OPpw,1068
30
+ java_codebase_rag-0.6.5.dist-info/METADATA,sha256=jbKFqnBab9XoMAtIhfX-A9n0ztxhOEPrNcuH5G9qrEM,17242
31
+ java_codebase_rag-0.6.5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
32
+ java_codebase_rag-0.6.5.dist-info/entry_points.txt,sha256=wsPZwot0Ui4JI3TIgW8LcbN8bNtKFbwQAlHAAJXfYgQ,117
33
+ java_codebase_rag-0.6.5.dist-info/top_level.txt,sha256=syQgi8XPBwY2ws_NZ1uRCxTf_s41NpshwEHNdcdnk3A,245
34
+ java_codebase_rag-0.6.5.dist-info/RECORD,,
@@ -60,16 +60,21 @@ if "detect_change" in _ck_params:
60
60
  PROJECT_ROOT = coco.ContextKey[Path]("java_lance_project_root")
61
61
  LANCE_DB = coco.ContextKey("java_lance_async_conn")
62
62
  EMBEDDER = coco.ContextKey[SentenceTransformerEmbedder]("java_lance_embedder")
63
+ IGNORE = coco.ContextKey[LayeredIgnore]("java_lance_layered_ignore")
63
64
  elif "tracked" in _ck_params:
64
65
  PROJECT_ROOT = coco.ContextKey[Path]("java_lance_project_root", tracked=False)
65
66
  LANCE_DB = coco.ContextKey("java_lance_async_conn", tracked=False)
66
67
  EMBEDDER = coco.ContextKey[SentenceTransformerEmbedder](
67
68
  "java_lance_embedder", tracked=False
68
69
  )
70
+ IGNORE = coco.ContextKey[LayeredIgnore](
71
+ "java_lance_layered_ignore", tracked=False
72
+ )
69
73
  else:
70
74
  PROJECT_ROOT = coco.ContextKey[Path]("java_lance_project_root")
71
75
  LANCE_DB = coco.ContextKey("java_lance_async_conn")
72
76
  EMBEDDER = coco.ContextKey[SentenceTransformerEmbedder]("java_lance_embedder")
77
+ IGNORE = coco.ContextKey[LayeredIgnore]("java_lance_layered_ignore")
73
78
 
74
79
  splitter = RecursiveSplitter()
75
80
 
@@ -292,6 +297,7 @@ async def coco_lifespan(builder: coco.EnvironmentBuilder) -> AsyncIterator[None]
292
297
  trust_remote_code=True,
293
298
  )
294
299
  builder.provide(EMBEDDER, embedder)
300
+ builder.provide(IGNORE, LayeredIgnore(root))
295
301
 
296
302
  uri = str(index_dir)
297
303
 
@@ -348,7 +354,8 @@ async def process_java_file(
348
354
  ) -> None:
349
355
  embedder = coco.use_context(EMBEDDER)
350
356
  project_root = coco.use_context(PROJECT_ROOT)
351
- if LayeredIgnore(project_root).is_ignored((project_root / file.file_path.path).resolve()):
357
+ ignore = coco.use_context(IGNORE)
358
+ if ignore.is_ignored((project_root / file.file_path.path).resolve()):
352
359
  return
353
360
  try:
354
361
  content = await file.read_text()
@@ -420,7 +427,8 @@ async def process_sql_file(
420
427
  ) -> None:
421
428
  embedder = coco.use_context(EMBEDDER)
422
429
  project_root = coco.use_context(PROJECT_ROOT)
423
- if LayeredIgnore(project_root).is_ignored((project_root / file.file_path.path).resolve()):
430
+ ignore = coco.use_context(IGNORE)
431
+ if ignore.is_ignored((project_root / file.file_path.path).resolve()):
424
432
  return
425
433
  try:
426
434
  content = await file.read_text()
@@ -468,7 +476,8 @@ async def process_yaml_file(
468
476
  ) -> None:
469
477
  embedder = coco.use_context(EMBEDDER)
470
478
  project_root = coco.use_context(PROJECT_ROOT)
471
- if LayeredIgnore(project_root).is_ignored((project_root / file.file_path.path).resolve()):
479
+ ignore = coco.use_context(IGNORE)
480
+ if ignore.is_ignored((project_root / file.file_path.path).resolve()):
472
481
  return
473
482
  try:
474
483
  content = await file.read_text()
path_filtering.py CHANGED
@@ -300,6 +300,7 @@ class LayeredIgnore:
300
300
  _scan_negation_any_bundle_ignore(self.project_root)
301
301
  or (use_gitignore and _scan_negation_any_gitignore(self.project_root))
302
302
  )
303
+ self._mega_cache: dict[str, tuple[list[str], GitIgnoreSpec, list[tuple[str, Path | None, int, str]]]] = {}
303
304
 
304
305
  def cocoindex_excluded_patterns(self) -> list[str]:
305
306
  """Patterns for CocoIndex ``PatternFilePathMatcher.excluded_patterns``.
@@ -332,6 +333,11 @@ class LayeredIgnore:
332
333
  return path.as_posix()
333
334
 
334
335
  def _mega(self, rel_project: str) -> tuple[list[str], GitIgnoreSpec, list[tuple[str, Path | None, int, str]]]:
336
+ # Cache by directory (parent of rel_project). _mega_build_for_rel reads only dir_parts,
337
+ # so files in the same directory share the same mega/spec/meta tuple.
338
+ cache_key = Path(rel_project).parent.as_posix()
339
+ if cache_key in self._mega_cache:
340
+ return self._mega_cache[cache_key]
335
341
  mega, meta = _mega_build_for_rel(
336
342
  self.project_root,
337
343
  rel_project,
@@ -340,7 +346,9 @@ class LayeredIgnore:
340
346
  project_ignore_path=self._project_ignore_path,
341
347
  project_lines=self._project_lines,
342
348
  )
343
- return mega, GitIgnoreSpec.from_lines(mega), meta
349
+ result = (mega, GitIgnoreSpec.from_lines(mega), meta)
350
+ self._mega_cache[cache_key] = result
351
+ return result
344
352
 
345
353
  def is_ignored(self, path: Path) -> bool:
346
354
  """Return whether ``path`` is ignored by any configured layer.