sourcecode 1.73.0__py3-none-any.whl → 2.0.0__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.
Files changed (38) hide show
  1. sourcecode/__init__.py +6 -2
  2. sourcecode/architecture_analyzer.py +63 -55
  3. sourcecode/ast_extractor.py +130 -135
  4. sourcecode/call_surface.py +112 -0
  5. sourcecode/canonical_ir.py +62 -1
  6. sourcecode/cli.py +220 -192
  7. sourcecode/context_graph.py +1022 -0
  8. sourcecode/contract_pipeline.py +16 -1
  9. sourcecode/dynamic_argument_surface.py +54 -0
  10. sourcecode/endpoint_literals.py +71 -0
  11. sourcecode/evidence_provider.py +90 -0
  12. sourcecode/explain.py +28 -13
  13. sourcecode/graph_analyzer.py +82 -8
  14. sourcecode/license.py +42 -13
  15. sourcecode/mcp/onboarding/applier.py +14 -6
  16. sourcecode/mcp/registry.py +22 -22
  17. sourcecode/mcp/runner.py +2 -2
  18. sourcecode/mcp/server.py +24 -24
  19. sourcecode/mcp_nudge.py +1 -1
  20. sourcecode/pr_comment_renderer.py +7 -6
  21. sourcecode/prepare_context.py +10 -13
  22. sourcecode/repository_ir.py +882 -141
  23. sourcecode/ris.py +1 -1
  24. sourcecode/semantic_analyzer.py +146 -9
  25. sourcecode/semantic_impact_engine.py +403 -0
  26. sourcecode/semantic_integration_engine.py +299 -0
  27. sourcecode/semantic_services.py +229 -0
  28. sourcecode/serializer.py +24 -32
  29. sourcecode/telemetry/consent.py +1 -1
  30. sourcecode/type_usage_surface.py +101 -0
  31. sourcecode/validation_surface.py +173 -205
  32. {sourcecode-1.73.0.dist-info → sourcecode-2.0.0.dist-info}/METADATA +125 -110
  33. {sourcecode-1.73.0.dist-info → sourcecode-2.0.0.dist-info}/RECORD +36 -29
  34. {sourcecode-1.73.0.dist-info → sourcecode-2.0.0.dist-info}/entry_points.txt +1 -0
  35. sourcecode/flow_analyzer.py +0 -671
  36. sourcecode/integration_detector.py +0 -435
  37. {sourcecode-1.73.0.dist-info → sourcecode-2.0.0.dist-info}/WHEEL +0 -0
  38. {sourcecode-1.73.0.dist-info → sourcecode-2.0.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py CHANGED
@@ -1,3 +1,7 @@
1
- """sourcecode — Deterministic codebase context maps for AI coding agents."""
1
+ """ASK Engine (CLI: ``ask``) — Deterministic codebase context maps for AI coding agents.
2
2
 
3
- __version__ = "1.73.0"
3
+ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode`` is
4
+ the legacy compatibility alias and the Python/PyPI package name. See
5
+ docs/PRODUCT_IDENTITY.md (normative)."""
6
+
7
+ __version__ = "2.0.0"
@@ -960,75 +960,83 @@ class ArchitectureAnalyzer:
960
960
  # Java annotation / base-class secondary scanner (C4)
961
961
  # ------------------------------------------------------------------
962
962
 
963
- _ANNOTATION_DECL_RE = re.compile(
964
- r"@interface\s+([A-Z][A-Za-z0-9_]+)", re.MULTILINE
965
- )
966
- _ANNOTATION_USAGE_RE = re.compile(
967
- r"@([A-Z][A-Za-z0-9_]+)(?:\s*\(([^)]*)\))?", re.MULTILINE
968
- )
969
- _EXTENDS_RE = re.compile(
970
- r"class\s+\w+\s+extends\s+([A-Z][A-Za-z0-9_]+)", re.MULTILINE
971
- )
972
- _ANNOTATION_PARAM_RE = re.compile(r"(\w+)\s*=\s*[\"']?([^,\"')]+)[\"']?")
973
- _MAX_JAVA_SCAN = 2000 # cap files to avoid runaway scanning
963
+ # Annotations never reported as "custom": Java/Spring/JPA/bean-validation
964
+ # built-ins. (Kept from the historical text scan; the counts now come from
965
+ # the ContextGraph's node-grounded `annotated_with` relations.)
966
+ _BUILTIN_ANNOTATIONS: frozenset = frozenset({
967
+ "Override", "SuppressWarnings", "Deprecated", "FunctionalInterface",
968
+ "SafeVarargs", "Retention", "Target", "Documented", "Inherited",
969
+ "RestController", "Service", "Repository", "Component", "Controller",
970
+ "Autowired", "Value", "Bean", "Configuration", "SpringBootApplication",
971
+ "EnableAutoConfiguration", "Transactional", "RequestMapping",
972
+ "GetMapping", "PostMapping", "PutMapping", "DeleteMapping",
973
+ "PathVariable", "RequestBody", "RequestParam", "ResponseBody",
974
+ "NotNull", "NotBlank", "Size", "Min", "Max", "Valid",
975
+ })
976
+ # @interface member return types the historical scan reported as parameters.
977
+ _ANNOTATION_PARAM_TYPES: frozenset = frozenset({"String", "int", "boolean", "Class"})
974
978
 
975
979
  def _scan_java_patterns(
976
980
  self,
977
981
  root: Path,
978
982
  java_paths: list[str],
979
983
  ) -> tuple[list[dict], list[dict]]:
980
- """Scan Java source files for custom @interface annotations and common base classes.
984
+ """Derive custom @interface annotations and common base classes from the
985
+ ContextGraph (the single structural access layer — no source parsing).
986
+
987
+ Counts are node-grounded: an annotation usage is an `annotated_with`
988
+ relation on a real symbol, and a subclass is an `extends` relation —
989
+ occurrences inside string literals, comments, or dead text never count
990
+ (the historical text scan could).
981
991
 
982
992
  Returns (custom_annotations, base_classes) lists for ArchitectureAnalysis.
983
993
  """
984
- from sourcecode.tree_utils import safe_read_text
994
+ from sourcecode.context_graph import ContextGraph
995
+
996
+ graph = ContextGraph.build(java_paths, root)
985
997
 
986
- annotation_decls: dict[str, dict] = {} # name → {params, files, usage_count}
987
- annotation_usage: dict[str, int] = {} # name → total usages across codebase
998
+ annotation_decls: dict[str, dict] = {} # name → {params, usage_count, purpose}
999
+ annotation_usage: dict[str, int] = {} # name → node-grounded usages
988
1000
  extends_counts: dict[str, int] = {} # base_class_name → subclass count
989
1001
 
990
- for path_str in java_paths[:self._MAX_JAVA_SCAN]:
991
- abs_path = root / path_str
992
- try:
993
- content = safe_read_text(abs_path)
994
- except OSError:
1002
+ # Custom annotation declarations: @interface nodes + their members with
1003
+ # the historically-reported parameter return types.
1004
+ for ann in graph.annotation_types():
1005
+ ann_name = ann.fqn.rsplit(".", 1)[-1]
1006
+ if ann_name in annotation_decls:
995
1007
  continue
1008
+ prefix = ann.fqn + "#"
1009
+ # Member return type from the node signature "(params)->ReturnType".
1010
+ params = [
1011
+ m.fqn[len(prefix):]
1012
+ for m in graph.symbols(kind="method")
1013
+ if m.fqn.startswith(prefix)
1014
+ and (m.signature or "").rsplit("->", 1)[-1] in self._ANNOTATION_PARAM_TYPES
1015
+ ]
1016
+ annotation_decls[ann_name] = {
1017
+ "name": ann_name,
1018
+ "parameters": params,
1019
+ "usage_count": 0,
1020
+ "purpose": "",
1021
+ }
996
1022
 
997
- # Custom annotation declarations: `public @interface FooAnnotation`
998
- for m in self._ANNOTATION_DECL_RE.finditer(content):
999
- ann_name = m.group(1)
1000
- if ann_name not in annotation_decls:
1001
- # Extract parameters from the annotation body (next ~500 chars)
1002
- body_start = m.end()
1003
- body = content[body_start:body_start + 500]
1004
- params = re.findall(r"(?:String|int|boolean|Class)\s+(\w+)\s*\(\)", body)
1005
- annotation_decls[ann_name] = {
1006
- "name": ann_name,
1007
- "parameters": params,
1008
- "usage_count": 0,
1009
- "purpose": "",
1010
- }
1011
-
1012
- # Annotation usage frequency
1013
- for m in self._ANNOTATION_USAGE_RE.finditer(content):
1014
- ann_name = m.group(1)
1015
- # Skip common Java built-ins
1016
- if ann_name in {
1017
- "Override", "SuppressWarnings", "Deprecated", "FunctionalInterface",
1018
- "SafeVarargs", "Retention", "Target", "Documented", "Inherited",
1019
- "RestController", "Service", "Repository", "Component", "Controller",
1020
- "Autowired", "Value", "Bean", "Configuration", "SpringBootApplication",
1021
- "EnableAutoConfiguration", "Transactional", "RequestMapping",
1022
- "GetMapping", "PostMapping", "PutMapping", "DeleteMapping",
1023
- "PathVariable", "RequestBody", "RequestParam", "ResponseBody",
1024
- "NotNull", "NotBlank", "Size", "Min", "Max", "Valid",
1025
- }:
1026
- continue
1027
- annotation_usage[ann_name] = annotation_usage.get(ann_name, 0) + 1
1028
-
1029
- # extends BaseClass — count subclasses per base
1030
- for m in self._EXTENDS_RE.finditer(content):
1031
- base = m.group(1)
1023
+ # Annotation usage frequency one count per annotated_with relation.
1024
+ for rel in graph.relations(kind="annotated_with"):
1025
+ ann_name = rel.target.lstrip("@").rsplit(".", 1)[-1]
1026
+ if not ann_name or not ann_name[0].isupper():
1027
+ continue
1028
+ if ann_name in self._BUILTIN_ANNOTATIONS:
1029
+ continue
1030
+ annotation_usage[ann_name] = annotation_usage.get(ann_name, 0) + 1
1031
+
1032
+ # extends BaseClass — count subclasses per base (class extends only,
1033
+ # matching the historical `class X extends Y` scan).
1034
+ class_fqns = {s.fqn for s in graph.symbols(kind="class")}
1035
+ for rel in graph.relations(kind="extends"):
1036
+ if rel.source not in class_fqns:
1037
+ continue # interface-extends-interface is not a subclass count
1038
+ base = re.sub(r"<.*", "", rel.target).rsplit(".", 1)[-1].strip()
1039
+ if base and base[0].isupper():
1032
1040
  extends_counts[base] = extends_counts.get(base, 0) + 1
1033
1041
 
1034
1042
  # Merge usage counts into annotation_decls
@@ -940,171 +940,136 @@ def _extract_python(path: str, source: str) -> FileContract:
940
940
 
941
941
 
942
942
  # ---------------------------------------------------------------------------
943
+ # Java extraction — projected from ContextGraph (single source of truth)
943
944
  # ---------------------------------------------------------------------------
944
- # Enhanced Java extraction (regex-based, annotation-aware)
945
- # ---------------------------------------------------------------------------
945
+ #
946
+ # The per-file Java contract is no longer parsed with a private regex pass.
947
+ # It is projected from the ContextGraph symbol/relation model — the same model
948
+ # repository_ir already builds — so the codebase carries exactly one Java parse
949
+ # path. ContractPipeline builds one repo-level graph and injects it via
950
+ # `AstExtractor.set_repo_graph`; a standalone `extract()` (no injected graph)
951
+ # falls back to a single-file graph build. Either way the projection below is
952
+ # the only code path, so behaviour is identical whether shared or per-file.
946
953
 
947
- _JAVA_IMPORT_RE = re.compile(r'^import\s+(?:static\s+)?([^;\s]+)\s*;', re.MULTILINE)
948
-
949
- # Single annotation on one line (captures name + optional parens args)
950
- _JAVA_ANNO_LINE_RE = re.compile(r'^\s*(@[\w.]+(?:\s*\([^)]*\))?)\s*$')
951
- # Class/interface/enum declaration line (public or package-private)
952
- _JAVA_CLASS_LINE_RE = re.compile(
953
- r'(?:public\s+)?(?:(?:abstract|final|static|sealed)\s+)*'
954
- r'(class|interface|enum|@interface)\s+(\w+)'
955
- r'(?:\s+extends\s+([\w.]+))?'
956
- r'(?:\s+implements\s+([\w.,\s<>]+?))?'
957
- r'(?=\s*[\{<])'
958
- )
959
- # Public method: up to 12 leading spaces, return type, name, open paren
960
- _JAVA_PUB_METHOD_LINE_RE = re.compile(
961
- r'^\s{0,12}public\s+(?:(?:static|final|synchronized|abstract|default)\s+)*'
962
- r'[\w<>\[\]?,\s]+?\s+(\w+)\s*\(',
963
- re.MULTILINE,
964
- )
965
- # @Autowired or @Inject field
966
- _JAVA_FIELD_DECL_RE = re.compile(
967
- r'^\s*(?:private|protected|public)?\s*'
968
- r'([\w<>.,\[\]? ]+?)\s+(\w+)\s*[;=]'
969
- )
954
+ _JAVA_DI_FIELD_ANNS: frozenset[str] = frozenset({"@Autowired", "@Inject"})
970
955
 
971
956
 
972
- def _java_collect_preceding_annotations(lines: list[str], decl_idx: int) -> list[str]:
973
- """Walk back from decl_idx and collect @annotation lines immediately before it."""
974
- annotations: list[str] = []
975
- i = decl_idx - 1
976
- while i >= 0:
977
- stripped = lines[i].strip()
978
- if not stripped or stripped.startswith("//") or stripped.startswith("*"):
979
- i -= 1
980
- continue
981
- m = re.match(r'(@[\w.]+(?:\s*\([^)]*\))?)', stripped)
982
- if m:
983
- annotations.insert(0, m.group(1))
984
- i -= 1
985
- else:
986
- break
987
- return annotations
957
+ def _java_simple_name(fqn: str) -> str:
958
+ """Declared name from a symbol FQN. For a member (`pkg.Type#member`) keep
959
+ the part after `#`; for a type or field keep the last dotted segment."""
960
+ return fqn.rsplit("#", 1)[-1].rsplit(".", 1)[-1]
988
961
 
989
962
 
990
- def _extract_java(path: str, source: str) -> FileContract:
991
- exports: list[ExportRecord] = []
992
- types: list[TypeDefinition] = []
993
- functions: list[FunctionSignature] = []
994
- imports: list[ImportRecord] = []
995
- autowired_fields: list[dict] = []
963
+ def _java_contract_from_graph(rel_path: str, graph: "Any") -> FileContract:
964
+ """Project a Java FileContract for one file from the ContextGraph.
996
965
 
997
- lines = source.splitlines()
966
+ Structural facts (types, members, imports, DI fields) are read from graph
967
+ nodes/edges instead of re-parsing source. Method signatures are the graph's
968
+ canonical compact form (`(Type)->Return`); annotations are node-grounded.
969
+ """
970
+ file_syms = [s for s in graph.symbols() if s.source_file == rel_path]
971
+ type_syms = [s for s in file_syms if s.kind in ("class", "interface", "enum", "annotation")]
972
+ type_fqns = {s.fqn for s in type_syms}
973
+
974
+ # extends / implements edges out of this file's types (targets are simple
975
+ # names for out-of-repo supertypes, resolved FQNs for in-repo ones — the
976
+ # same forms the old regex captured for the unresolved case).
977
+ extends_of: dict[str, list[str]] = {}
978
+ implements_of: dict[str, list[str]] = {}
979
+ import_targets: set[str] = set()
980
+ for r in graph.relations():
981
+ if r.source not in type_fqns:
982
+ continue
983
+ if r.kind == "extends":
984
+ extends_of.setdefault(r.source, []).append(r.target)
985
+ elif r.kind == "implements":
986
+ implements_of.setdefault(r.source, []).append(r.target)
987
+ elif r.kind == "imports":
988
+ import_targets.add(r.target)
998
989
 
999
- # Pass 1: collect imports
1000
- seen_sources: set[str] = set()
1001
- for m in _JAVA_IMPORT_RE.finditer(source):
1002
- full_import = m.group(1).strip()
1003
- if full_import not in seen_sources:
1004
- seen_sources.add(full_import)
1005
- imports.append(ImportRecord(source=full_import, kind="named", symbols=[]))
990
+ exports: list[ExportRecord] = []
991
+ types: list[TypeDefinition] = []
992
+ for s in sorted(type_syms, key=lambda s: s.fqn):
993
+ name = _java_simple_name(s.fqn)
994
+ export_kind = "class" if s.kind == "annotation" else s.kind
995
+ parents = extends_of.get(s.fqn, [])
996
+ impls = sorted(implements_of.get(s.fqn, []))
997
+ exports.append(ExportRecord(
998
+ name=name,
999
+ kind=export_kind,
1000
+ annotations=list(s.annotations),
1001
+ extends=parents[0] if parents else None,
1002
+ implements=impls,
1003
+ ))
1004
+ types.append(TypeDefinition(
1005
+ name=name,
1006
+ kind=export_kind,
1007
+ fields=[],
1008
+ extends=sorted(parents),
1009
+ ))
1006
1010
 
1007
- # Pass 2: line-by-line scan for classes, methods, @Autowired fields
1008
- class_names: set[str] = set()
1011
+ # Public methods (match the historical `public`-keyword scope). Constructors
1012
+ # are a distinct node kind and stay excluded, as the old return-type regex
1013
+ # excluded them. Dedup by name, keeping first in FQN order.
1014
+ functions: list[FunctionSignature] = []
1009
1015
  seen_methods: set[str] = set()
1010
- autowired_pending = False
1011
-
1012
- for idx, line in enumerate(lines):
1013
- stripped = line.strip()
1014
-
1015
- # Detect @Autowired / @Inject annotations
1016
- if stripped.startswith("@Autowired") or stripped.startswith("@Inject"):
1017
- autowired_pending = True
1016
+ for s in sorted((m for m in file_syms if m.kind == "method"), key=lambda s: s.fqn):
1017
+ if "public" not in s.modifiers:
1018
1018
  continue
1019
-
1020
- # Capture autowired field on next non-annotation, non-blank line
1021
- if autowired_pending and stripped and not stripped.startswith("@"):
1022
- autowired_pending = False
1023
- fm = _JAVA_FIELD_DECL_RE.match(line)
1024
- if fm:
1025
- type_name = fm.group(1).strip().split()[-1] # last word = simple type
1026
- field_name = fm.group(2).strip()
1027
- if field_name and type_name and field_name not in {"class", "interface"}:
1028
- autowired_fields.append({"type": type_name, "name": field_name})
1029
- elif stripped and not stripped.startswith("@"):
1030
- autowired_pending = False
1031
-
1032
- # Class/interface/enum declaration
1033
- cm = _JAVA_CLASS_LINE_RE.search(stripped)
1034
- if cm and ("class " in stripped or "interface " in stripped or "enum " in stripped):
1035
- kind_kw = cm.group(1) # class | interface | enum | @interface
1036
- name = cm.group(2)
1037
- extends_str = cm.group(3)
1038
- implements_str = cm.group(4)
1039
-
1040
- annotations = _java_collect_preceding_annotations(lines, idx)
1041
- all_extends: list[str] = []
1042
- if extends_str:
1043
- all_extends.append(extends_str.strip())
1044
-
1045
- implements_list: list[str] = []
1046
- if implements_str:
1047
- implements_list = [i.strip() for i in implements_str.split(",") if i.strip()]
1048
-
1049
- export_kind = "class" if kind_kw in ("class", "@interface") else kind_kw
1050
- exports.append(ExportRecord(
1051
- name=name,
1052
- kind=export_kind,
1053
- annotations=annotations,
1054
- extends=extends_str.strip() if extends_str else None,
1055
- implements=implements_list,
1056
- ))
1057
- types.append(TypeDefinition(
1058
- name=name,
1059
- kind=export_kind,
1060
- fields=[],
1061
- extends=all_extends,
1062
- ))
1063
- class_names.add(name)
1064
-
1065
- # Pass 3: public methods with their preceding annotations
1066
- for m in _JAVA_PUB_METHOD_LINE_RE.finditer(source):
1067
- sig_text = m.group(0).strip()
1068
- mname = m.group(1)
1069
- if (mname in class_names or mname in seen_methods
1070
- or mname in {"if", "for", "while", "switch", "return", "new"}):
1019
+ mname = _java_simple_name(s.fqn)
1020
+ if mname in seen_methods:
1071
1021
  continue
1072
1022
  seen_methods.add(mname)
1073
- # Find line index for this match to collect preceding annotations
1074
- line_start = source.count("\n", 0, m.start())
1075
- annotations = _java_collect_preceding_annotations(lines, line_start)
1076
1023
  exports.append(ExportRecord(
1077
1024
  name=mname,
1078
1025
  kind="method",
1079
- annotations=annotations,
1080
- signature=sig_text,
1026
+ annotations=list(s.annotations),
1027
+ signature=s.signature or None,
1081
1028
  ))
1082
1029
  functions.append(FunctionSignature(
1083
1030
  name=mname,
1084
- signature=sig_text,
1031
+ signature=s.signature or "",
1085
1032
  async_=False,
1086
1033
  exported=True,
1087
1034
  return_type=None,
1088
1035
  ))
1089
1036
 
1037
+ # DI fields: @Autowired / @Inject field nodes → {type, name}. The field
1038
+ # node's signature is "Type name"; take the type's simple last token.
1039
+ autowired_fields: list[dict] = []
1040
+ for s in sorted((f for f in file_syms if f.kind == "field"), key=lambda s: s.fqn):
1041
+ if not any(a in _JAVA_DI_FIELD_ANNS for a in s.annotations):
1042
+ continue
1043
+ sig = s.signature or ""
1044
+ type_tok = sig.split()[0] if sig else ""
1045
+ type_name = re.sub(r"<.*", "", type_tok).rsplit(".", 1)[-1]
1046
+ field_name = _java_simple_name(s.fqn)
1047
+ if field_name and type_name:
1048
+ autowired_fields.append({"type": type_name, "name": field_name})
1049
+
1050
+ imports = [
1051
+ ImportRecord(source=fqn, kind="named", symbols=[])
1052
+ for fqn in sorted(import_targets)
1053
+ ]
1054
+
1090
1055
  # External deps: top-2 package segments, skip java.* / javax.*
1091
1056
  deps = sorted({
1092
- ".".join(imp.source.split(".")[:2])
1093
- for imp in imports
1094
- if not imp.source.startswith("java.") and not imp.source.startswith("javax.")
1095
- and len(imp.source.split(".")) >= 2
1057
+ ".".join(fqn.split(".")[:2])
1058
+ for fqn in import_targets
1059
+ if not fqn.startswith("java.") and not fqn.startswith("javax.")
1060
+ and len(fqn.split(".")) >= 2
1096
1061
  })
1097
1062
 
1098
1063
  return FileContract(
1099
- path=path,
1064
+ path=rel_path,
1100
1065
  language="java",
1101
1066
  exports=exports,
1102
- imports=sorted(imports, key=lambda i: i.source)[:30],
1067
+ imports=imports[:30],
1103
1068
  functions=sorted(functions, key=lambda f: f.name)[:20],
1104
1069
  types=sorted(types, key=lambda t: t.name),
1105
1070
  dependencies=deps[:20],
1106
1071
  autowired_fields=autowired_fields[:20],
1107
- extraction_method="heuristic",
1072
+ extraction_method="graph",
1108
1073
  )
1109
1074
 
1110
1075
 
@@ -1245,6 +1210,26 @@ class AstExtractor:
1245
1210
  self.max_file_size = max_file_size
1246
1211
  self._ts_checked = False
1247
1212
  self._ts_ok = False
1213
+ # Repo-level ContextGraph for Java projection, injected by
1214
+ # ContractPipeline so all files share one parse. (root, graph).
1215
+ self._repo_graph_root: Optional[Path] = None
1216
+ self._repo_graph: Optional[Any] = None
1217
+
1218
+ def set_repo_graph(self, graph: "Any", root: Path) -> None:
1219
+ """Inject a pre-built repo-level ContextGraph so per-file Java contracts
1220
+ project from it instead of each building a single-file graph."""
1221
+ self._repo_graph = graph
1222
+ self._repo_graph_root = root
1223
+
1224
+ def _java_graph_for(self, root: Path) -> "Any":
1225
+ """Return the shared graph when it covers `root`, else build a
1226
+ single-file-scope graph for standalone extraction."""
1227
+ if self._repo_graph is not None and self._repo_graph_root == root:
1228
+ return self._repo_graph
1229
+ from sourcecode.context_graph import ContextGraph
1230
+ from sourcecode.repository_ir import find_java_files
1231
+ files = find_java_files(root)
1232
+ return ContextGraph.build(files, root)
1248
1233
 
1249
1234
  def _ensure_ts(self) -> bool:
1250
1235
  if not self._ts_checked:
@@ -1268,12 +1253,13 @@ class AstExtractor:
1268
1253
  if language is None:
1269
1254
  return None
1270
1255
 
1256
+ rel_path = str(path.relative_to(root)).replace("\\", "/") if root else path.name
1257
+
1271
1258
  try:
1272
1259
  stat = path.stat()
1273
1260
  if stat.st_size > self.max_file_size:
1274
- rel = str(path.relative_to(root)) if root else path.name
1275
1261
  return FileContract(
1276
- path=rel,
1262
+ path=str(path.relative_to(root)) if root else path.name,
1277
1263
  language=language,
1278
1264
  extraction_method="heuristic",
1279
1265
  limitations=[f"file_too_large: {stat.st_size} bytes > {self.max_file_size}"],
@@ -1281,17 +1267,26 @@ class AstExtractor:
1281
1267
  except OSError:
1282
1268
  return None
1283
1269
 
1270
+ # Java is projected from the ContextGraph, not re-parsed from source.
1271
+ # Use the repo-level graph ContractPipeline injected when it covers this
1272
+ # root; otherwise build a single-file graph (standalone `extract`).
1273
+ if language == "java" and root is not None:
1274
+ graph = self._java_graph_for(root)
1275
+ contract = _java_contract_from_graph(rel_path, graph)
1276
+ contract.role = _detect_role(rel_path, contract)
1277
+ return contract
1278
+
1284
1279
  try:
1285
1280
  source = path.read_text(encoding="utf-8", errors="replace")
1286
1281
  except OSError:
1287
1282
  return None
1288
1283
 
1289
- rel_path = str(path.relative_to(root)).replace("\\", "/") if root else path.name
1290
-
1291
1284
  if language == "python":
1292
1285
  contract = _extract_python(rel_path, source)
1293
1286
  elif language == "java":
1294
- contract = _extract_java(rel_path, source)
1287
+ # No root to resolve a graph against — degrade to an empty contract
1288
+ # rather than reintroduce a regex parser.
1289
+ contract = FileContract(path=rel_path, language="java", extraction_method="graph")
1295
1290
  else:
1296
1291
  if self._ensure_ts():
1297
1292
  lang_obj = _get_ts_lang(language)
@@ -0,0 +1,112 @@
1
+ """Outgoing call surface — the first consumer built on the Semantic IR P-A
2
+ primitives (ADR-0001 §6, P5.1 proof point).
3
+
4
+ An *outgoing call surface* is, for one method/constructor body, the complete set
5
+ of things it calls and instantiates — in source order, with receiver, argument
6
+ count, and resolution status. Computing it naively requires parsing method
7
+ bodies; here it is a pure query over the Body-Reference atoms
8
+ (`ContextGraph.invocations_in` / `instantiations_in`). This module reads **no
9
+ source** (INV-1): its only input is a built `ContextGraph`.
10
+
11
+ Why this is not already derivable from the L1 `calls` edge set: L1 edges are
12
+ dedup, class/method-level, and record only *resolved* links — an external call
13
+ like `repo.save(o)` (where `repo` is an injected field of a type outside the
14
+ module) produces **no** `calls` edge, yet it is part of the method's real call
15
+ surface. The P-A atoms retain exactly that discarded knowledge: unresolved
16
+ external calls, call ordering, argument counts, and duplicate call sites. This
17
+ module surfaces it.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from typing import TYPE_CHECKING, Optional
23
+
24
+ if TYPE_CHECKING: # avoid import cycle; only a type hint
25
+ from sourcecode.context_graph import ContextGraph
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class OutgoingCall:
30
+ """One method-call occurrence leaving a body. `resolved` is True when the IR
31
+ could bind the callee to a concrete symbol (`resolved_fqn` set); False for
32
+ honest-unresolved external / overloaded / dynamic calls."""
33
+
34
+ callee: str
35
+ receiver: Optional[str]
36
+ arg_count: int
37
+ resolved_fqn: Optional[str]
38
+ occurrence: int
39
+
40
+ @property
41
+ def resolved(self) -> bool:
42
+ return self.resolved_fqn is not None
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class OutgoingInstantiation:
47
+ """One `new Type(...)` occurrence leaving a body."""
48
+
49
+ type: str
50
+ arg_count: int
51
+ resolved_fqn: Optional[str]
52
+ occurrence: int
53
+
54
+ @property
55
+ def resolved(self) -> bool:
56
+ return self.resolved_fqn is not None
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class CallSurface:
61
+ """The full outgoing surface of one owner (method/constructor), ordered by
62
+ source occurrence within each stream."""
63
+
64
+ owner_fqn: str
65
+ calls: tuple[OutgoingCall, ...]
66
+ instantiations: tuple[OutgoingInstantiation, ...]
67
+
68
+ @property
69
+ def resolved_calls(self) -> tuple[OutgoingCall, ...]:
70
+ return tuple(c for c in self.calls if c.resolved)
71
+
72
+ @property
73
+ def unresolved_calls(self) -> tuple[OutgoingCall, ...]:
74
+ """External / overloaded / dynamic calls the IR does not bind — the
75
+ blind spot the L1 `calls` edge set drops entirely."""
76
+ return tuple(c for c in self.calls if not c.resolved)
77
+
78
+ @property
79
+ def is_empty(self) -> bool:
80
+ return not self.calls and not self.instantiations
81
+
82
+
83
+ def outgoing_call_surface(graph: "ContextGraph", owner_fqn: str) -> CallSurface:
84
+ """Outgoing call surface for a single method/constructor owner. Returns an
85
+ empty (non-None) surface when the owner has no body atoms."""
86
+ calls = tuple(
87
+ OutgoingCall(
88
+ callee=i.callee, receiver=i.receiver, arg_count=i.arg_count,
89
+ resolved_fqn=i.resolved_fqn, occurrence=i.occurrence,
90
+ )
91
+ for i in graph.invocations_in(owner_fqn)
92
+ )
93
+ insts = tuple(
94
+ OutgoingInstantiation(
95
+ type=n.type, arg_count=n.arg_count,
96
+ resolved_fqn=n.resolved_fqn, occurrence=n.occurrence,
97
+ )
98
+ for n in graph.instantiations_in(owner_fqn)
99
+ )
100
+ return CallSurface(owner_fqn=owner_fqn, calls=calls, instantiations=insts)
101
+
102
+
103
+ def call_surfaces(graph: "ContextGraph") -> dict[str, CallSurface]:
104
+ """Outgoing call surface for every method/constructor with body atoms, keyed
105
+ by owner FQN and sorted for determinism. Owners with an empty surface are
106
+ omitted."""
107
+ out: dict[str, CallSurface] = {}
108
+ for s in graph.symbols(kind="method") + graph.symbols(kind="constructor"):
109
+ surface = outgoing_call_surface(graph, s.fqn)
110
+ if not surface.is_empty:
111
+ out[s.fqn] = surface
112
+ return {k: out[k] for k in sorted(out)}