feat: semantic type-reference edges for Swift, Kotlin, PHP, Rust, and Go (#1015)
This commit is contained in:
+869
-43
File diff suppressed because it is too large
Load Diff
Vendored
+28
@@ -21,6 +21,34 @@ func (s *Server) Stop() {
|
|||||||
fmt.Println("stopped")
|
fmt.Println("stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Logger interface {
|
||||||
|
Log(msg string)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Reader interface {
|
||||||
|
Read() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReaderLogger interface {
|
||||||
|
Logger
|
||||||
|
Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseProcessor struct{}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
value int
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataProcessor struct {
|
||||||
|
BaseProcessor
|
||||||
|
current *Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DataProcessor) Build(input *DataProcessor) (*Result, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
s := NewServer(8080)
|
s := NewServer(8080)
|
||||||
s.Start()
|
s.Start()
|
||||||
|
|||||||
Vendored
+18
@@ -17,6 +17,24 @@ class HttpClient(private val config: Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Loggable {
|
||||||
|
fun log()
|
||||||
|
}
|
||||||
|
|
||||||
|
open class BaseProcessor
|
||||||
|
|
||||||
|
class Result<T>
|
||||||
|
|
||||||
|
class DataProcessor : BaseProcessor(), Loggable {
|
||||||
|
var current: Result<DataProcessor> = Result()
|
||||||
|
|
||||||
|
fun run(input: DataProcessor): Result<DataProcessor> {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun log() {}
|
||||||
|
}
|
||||||
|
|
||||||
fun createClient(baseUrl: String): HttpClient {
|
fun createClient(baseUrl: String): HttpClient {
|
||||||
val config = Config(baseUrl, 30)
|
val config = Config(baseUrl, 30)
|
||||||
return HttpClient(config)
|
return HttpClient(config)
|
||||||
|
|||||||
Vendored
+33
@@ -33,6 +33,39 @@ class ApiClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Loggable
|
||||||
|
{
|
||||||
|
public function log(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
trait HasName
|
||||||
|
{
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseProcessor {}
|
||||||
|
|
||||||
|
class Result {}
|
||||||
|
|
||||||
|
class DataProcessor extends BaseProcessor implements Loggable
|
||||||
|
{
|
||||||
|
use HasName;
|
||||||
|
|
||||||
|
private Result $current;
|
||||||
|
|
||||||
|
public function run(DataProcessor $input): Result
|
||||||
|
{
|
||||||
|
return new Result();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseResponse(string $raw): array
|
function parseResponse(string $raw): array
|
||||||
{
|
{
|
||||||
return json_decode($raw, true);
|
return json_decode($raw, true);
|
||||||
|
|||||||
Vendored
+26
@@ -25,3 +25,29 @@ fn build_graph(edges: Vec<(String, String)>) -> Graph {
|
|||||||
}
|
}
|
||||||
g
|
g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trait Processor {
|
||||||
|
fn run(&self);
|
||||||
|
}
|
||||||
|
|
||||||
|
trait Logger: Processor {
|
||||||
|
fn log(&self);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Result<T> {
|
||||||
|
value: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DataProcessor {
|
||||||
|
current: Result<DataProcessor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Processor for DataProcessor {
|
||||||
|
fn run(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataProcessor {
|
||||||
|
fn build(input: DataProcessor) -> Result<DataProcessor> {
|
||||||
|
Result { value: input }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+10
-1
@@ -9,8 +9,13 @@ protocol Loggable {
|
|||||||
func log()
|
func log()
|
||||||
}
|
}
|
||||||
|
|
||||||
class DataProcessor: Processor {
|
class BaseProcessor {}
|
||||||
|
|
||||||
|
class Result<T> {}
|
||||||
|
|
||||||
|
class DataProcessor: BaseProcessor, Processor {
|
||||||
private var items: [String] = []
|
private var items: [String] = []
|
||||||
|
var current: Result<DataProcessor> = Result<DataProcessor>()
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
@@ -24,6 +29,10 @@ class DataProcessor: Processor {
|
|||||||
return validate(items)
|
return validate(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func run(input: DataProcessor) -> Result<DataProcessor> {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
private func validate(_ data: [String]) -> [String] {
|
private func validate(_ data: [String]) -> [String] {
|
||||||
return data.filter { !$0.isEmpty }
|
return data.filter { !$0.isEmpty }
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-22
@@ -348,6 +348,20 @@ def test_kotlin_emits_in_file_calls():
|
|||||||
assert ("createClient()", "HttpClient") in calls
|
assert ("createClient()", "HttpClient") in calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_kotlin_splits_inherits_and_implements():
|
||||||
|
r = extract_kotlin(FIXTURES / "sample.kt")
|
||||||
|
assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "inherits")
|
||||||
|
assert ("DataProcessor", "Loggable") in _edge_labels(r, "implements")
|
||||||
|
|
||||||
|
|
||||||
|
def test_kotlin_parameter_return_generic_and_field_contexts():
|
||||||
|
r = extract_kotlin(FIXTURES / "sample.kt")
|
||||||
|
assert ("run", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
|
||||||
|
assert ("run", "Result") in _edge_labels(r, "references", "return_type")
|
||||||
|
assert ("run", "DataProcessor") in _edge_labels(r, "references", "generic_arg")
|
||||||
|
assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
|
||||||
|
|
||||||
|
|
||||||
# ── Scala ─────────────────────────────────────────────────────────────────────
|
# ── Scala ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_scala_no_error():
|
def test_scala_no_error():
|
||||||
@@ -474,6 +488,20 @@ def test_php_event_listener_links_event_to_listener():
|
|||||||
assert any("UserRegistered" in src and "SendWelcomeEmail" in tgt for src, tgt in listened)
|
assert any("UserRegistered" in src and "SendWelcomeEmail" in tgt for src, tgt in listened)
|
||||||
|
|
||||||
|
|
||||||
|
def test_php_splits_inherits_implements_mixes_in():
|
||||||
|
r = extract_php(FIXTURES / "sample.php")
|
||||||
|
assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "inherits")
|
||||||
|
assert ("DataProcessor", "Loggable") in _edge_labels(r, "implements")
|
||||||
|
assert ("DataProcessor", "HasName") in _edge_labels(r, "mixes_in")
|
||||||
|
|
||||||
|
|
||||||
|
def test_php_property_parameter_and_return_contexts():
|
||||||
|
r = extract_php(FIXTURES / "sample.php")
|
||||||
|
assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
|
||||||
|
assert ("run", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
|
||||||
|
assert ("run", "Result") in _edge_labels(r, "references", "return_type")
|
||||||
|
|
||||||
|
|
||||||
# ── Swift ────────────────────────────────────────────────────────────────────
|
# ── Swift ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_swift_no_error():
|
def test_swift_no_error():
|
||||||
@@ -568,31 +596,28 @@ def test_swift_extension_does_not_duplicate_type_node():
|
|||||||
config_nodes = [n for n in r["nodes"] if n["label"] == "Config"]
|
config_nodes = [n for n in r["nodes"] if n["label"] == "Config"]
|
||||||
assert len(config_nodes) == 1, f"Config should appear once, got {len(config_nodes)}"
|
assert len(config_nodes) == 1, f"Config should appear once, got {len(config_nodes)}"
|
||||||
|
|
||||||
def test_swift_conformance_edge():
|
def test_swift_protocol_conformance_emits_implements():
|
||||||
r = extract_swift(FIXTURES / "sample.swift")
|
r = extract_swift(FIXTURES / "sample.swift")
|
||||||
inherits_edges = [e for e in r["edges"] if e["relation"] == "inherits"]
|
assert ("DataProcessor", "Processor") in _edge_labels(r, "implements")
|
||||||
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
|
|
||||||
found = False
|
|
||||||
for e in inherits_edges:
|
|
||||||
src_label = node_by_id.get(e["source"], "")
|
|
||||||
tgt_label = node_by_id.get(e["target"], "")
|
|
||||||
if "DataProcessor" in src_label and "Processor" in tgt_label:
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
assert found, "DataProcessor should have inherits edge to Processor"
|
|
||||||
|
|
||||||
def test_swift_extension_conformance_edge():
|
|
||||||
|
def test_swift_extension_conformance_emits_implements():
|
||||||
r = extract_swift(FIXTURES / "sample.swift")
|
r = extract_swift(FIXTURES / "sample.swift")
|
||||||
inherits_edges = [e for e in r["edges"] if e["relation"] == "inherits"]
|
assert ("DataProcessor", "Loggable") in _edge_labels(r, "implements")
|
||||||
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
|
|
||||||
found = False
|
|
||||||
for e in inherits_edges:
|
def test_swift_splits_inherits_and_implements():
|
||||||
src_label = node_by_id.get(e["source"], "")
|
r = extract_swift(FIXTURES / "sample.swift")
|
||||||
tgt_label = node_by_id.get(e["target"], "")
|
assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "inherits")
|
||||||
if "DataProcessor" in src_label and "Loggable" in tgt_label:
|
assert ("DataProcessor", "Processor") in _edge_labels(r, "implements")
|
||||||
found = True
|
|
||||||
break
|
|
||||||
assert found, "extension should add conformance edge DataProcessor -> Loggable"
|
def test_swift_parameter_return_generic_and_field_contexts():
|
||||||
|
r = extract_swift(FIXTURES / "sample.swift")
|
||||||
|
assert ("run", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
|
||||||
|
assert ("run", "Result") in _edge_labels(r, "references", "return_type")
|
||||||
|
assert ("run", "DataProcessor") in _edge_labels(r, "references", "generic_arg")
|
||||||
|
assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
|
||||||
|
|
||||||
def test_swift_emits_calls():
|
def test_swift_emits_calls():
|
||||||
r = extract_swift(FIXTURES / "sample.swift")
|
r = extract_swift(FIXTURES / "sample.swift")
|
||||||
|
|||||||
@@ -28,6 +28,22 @@ def _edges_with_relation(result, *relations):
|
|||||||
return [e for e in result["edges"] if e["relation"] in relations]
|
return [e for e in result["edges"] if e["relation"] in relations]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_symbol_label(label: str) -> str:
|
||||||
|
return label.strip("()").lstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_labels(result, relation, context=None):
|
||||||
|
labels = {n["id"]: _normalize_symbol_label(n["label"]) for n in result["nodes"]}
|
||||||
|
pairs = set()
|
||||||
|
for e in result["edges"]:
|
||||||
|
if e.get("relation") != relation:
|
||||||
|
continue
|
||||||
|
if context is not None and e.get("context") != context:
|
||||||
|
continue
|
||||||
|
pairs.add((labels.get(e["source"], e["source"]), labels.get(e["target"], e["target"])))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
# ── TypeScript ────────────────────────────────────────────────────────────────
|
# ── TypeScript ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_ts_finds_class():
|
def test_ts_finds_class():
|
||||||
@@ -127,6 +143,149 @@ def test_go_no_dangling_edges():
|
|||||||
assert e["source"] in node_ids
|
assert e["source"] in node_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_go_embeds_struct_field():
|
||||||
|
r = extract_go(FIXTURES / "sample.go")
|
||||||
|
assert ("DataProcessor", "BaseProcessor") in _edge_labels(r, "embeds")
|
||||||
|
|
||||||
|
|
||||||
|
def test_go_interface_embedding_emits_embeds():
|
||||||
|
r = extract_go(FIXTURES / "sample.go")
|
||||||
|
assert ("ReaderLogger", "Logger") in _edge_labels(r, "embeds")
|
||||||
|
|
||||||
|
|
||||||
|
def test_go_struct_named_field_emits_field_context():
|
||||||
|
r = extract_go(FIXTURES / "sample.go")
|
||||||
|
assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
|
||||||
|
|
||||||
|
|
||||||
|
def test_go_method_parameter_return_contexts():
|
||||||
|
r = extract_go(FIXTURES / "sample.go")
|
||||||
|
assert ("Build", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
|
||||||
|
assert ("Build", "Result") in _edge_labels(r, "references", "return_type")
|
||||||
|
|
||||||
|
|
||||||
|
def test_go_method_declaration_emits_refs_only_when_name_present():
|
||||||
|
"""Regression: review feedback flagged a hypothetical UnboundLocalError in
|
||||||
|
extract_go's method_declaration branch if `name_node` were None. Statically
|
||||||
|
verify that every use of `method_nid` (and the `emit_go_method_refs` call
|
||||||
|
that consumes it) is guarded by a `name_node` truthiness check — either
|
||||||
|
nested inside `if name_node:` or following an early `if not name_node: return`.
|
||||||
|
Same for function_declaration and `func_nid`.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import inspect
|
||||||
|
from graphify.extract import extract_go
|
||||||
|
|
||||||
|
tree = ast.parse(inspect.getsource(extract_go))
|
||||||
|
|
||||||
|
def _find_branch(root: ast.AST, type_literal: str) -> ast.If | None:
|
||||||
|
"""Return the `if t == '<type_literal>':` branch inside the walk function."""
|
||||||
|
for child in ast.walk(root):
|
||||||
|
if (isinstance(child, ast.If)
|
||||||
|
and isinstance(child.test, ast.Compare)
|
||||||
|
and isinstance(child.test.left, ast.Name)
|
||||||
|
and child.test.left.id == "t"
|
||||||
|
and len(child.test.comparators) == 1
|
||||||
|
and isinstance(child.test.comparators[0], ast.Constant)
|
||||||
|
and child.test.comparators[0].value == type_literal):
|
||||||
|
return child
|
||||||
|
return None
|
||||||
|
|
||||||
|
method_branch = _find_branch(tree, "method_declaration")
|
||||||
|
function_branch = _find_branch(tree, "function_declaration")
|
||||||
|
assert method_branch is not None, "method_declaration branch not found in extract_go"
|
||||||
|
assert function_branch is not None, "function_declaration branch not found in extract_go"
|
||||||
|
|
||||||
|
def _is_early_return_on_falsy_name_node(stmt: ast.AST) -> bool:
|
||||||
|
"""True iff `stmt` is `if not name_node: return` (or raise/continue/break)."""
|
||||||
|
if not isinstance(stmt, ast.If):
|
||||||
|
return False
|
||||||
|
test = stmt.test
|
||||||
|
is_falsy_check = (
|
||||||
|
isinstance(test, ast.UnaryOp)
|
||||||
|
and isinstance(test.op, ast.Not)
|
||||||
|
and isinstance(test.operand, ast.Name)
|
||||||
|
and test.operand.id == "name_node"
|
||||||
|
)
|
||||||
|
if not is_falsy_check:
|
||||||
|
return False
|
||||||
|
terminators = (ast.Return, ast.Raise, ast.Continue, ast.Break)
|
||||||
|
return any(isinstance(s, terminators) for s in stmt.body)
|
||||||
|
|
||||||
|
def _guarded_by_name_node(branch: ast.If, var_name: str) -> bool:
|
||||||
|
"""True iff every read of `var_name` in `branch` is guarded by a
|
||||||
|
`name_node` truthiness check — either lexically nested under
|
||||||
|
`if name_node:` or after a preceding `if not name_node: return`."""
|
||||||
|
parents: dict[int, ast.AST] = {}
|
||||||
|
for parent in ast.walk(branch):
|
||||||
|
for child in ast.iter_child_nodes(parent):
|
||||||
|
parents[id(child)] = parent
|
||||||
|
|
||||||
|
def _stmt_chain(start: ast.AST) -> list[tuple[ast.stmt, list[ast.stmt]]]:
|
||||||
|
"""Walk up to each enclosing statement-list, returning (stmt, siblings)."""
|
||||||
|
chain: list[tuple[ast.stmt, list[ast.stmt]]] = []
|
||||||
|
cur: ast.AST | None = start
|
||||||
|
while cur is not None:
|
||||||
|
parent = parents.get(id(cur))
|
||||||
|
if parent is None:
|
||||||
|
break
|
||||||
|
if isinstance(cur, ast.stmt):
|
||||||
|
for attr in ("body", "orelse", "finalbody"):
|
||||||
|
siblings = getattr(parent, attr, None)
|
||||||
|
if isinstance(siblings, list) and cur in siblings:
|
||||||
|
chain.append((cur, siblings))
|
||||||
|
break
|
||||||
|
cur = parent
|
||||||
|
return chain
|
||||||
|
|
||||||
|
def _is_guarded(use: ast.AST) -> bool:
|
||||||
|
for stmt, siblings in _stmt_chain(use):
|
||||||
|
parent = parents.get(id(stmt))
|
||||||
|
# Case 1: lexically nested under `if name_node:` body
|
||||||
|
if (isinstance(parent, ast.If)
|
||||||
|
and isinstance(parent.test, ast.Name)
|
||||||
|
and parent.test.id == "name_node"
|
||||||
|
and stmt in parent.body):
|
||||||
|
return True
|
||||||
|
# Case 2: a preceding sibling is `if not name_node: return`
|
||||||
|
idx = siblings.index(stmt)
|
||||||
|
if any(_is_early_return_on_falsy_name_node(s) for s in siblings[:idx]):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for node in ast.walk(branch):
|
||||||
|
if isinstance(node, ast.Name) and node.id == var_name:
|
||||||
|
if not _is_guarded(node):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
assert _guarded_by_name_node(method_branch, "method_nid"), (
|
||||||
|
"method_nid use is not guarded by a name_node check in method_declaration branch"
|
||||||
|
)
|
||||||
|
assert _guarded_by_name_node(function_branch, "func_nid"), (
|
||||||
|
"func_nid use is not guarded by a name_node check in function_declaration branch"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Negative control: confirm the checker would actually reject the buggy
|
||||||
|
# layout the reviewer described. A `method_nid` reference dangling without
|
||||||
|
# any name_node guard must be caught.
|
||||||
|
bad_source = (
|
||||||
|
"def walk(node):\n"
|
||||||
|
" if t == 'method_declaration':\n"
|
||||||
|
" name_node = node.child_by_field_name('name')\n"
|
||||||
|
" if name_node:\n"
|
||||||
|
" method_nid = make_id('x')\n"
|
||||||
|
" emit_go_method_refs(node, method_nid, 1)\n"
|
||||||
|
" return\n"
|
||||||
|
)
|
||||||
|
bad_tree = ast.parse(bad_source)
|
||||||
|
bad_branch = _find_branch(bad_tree, "method_declaration")
|
||||||
|
assert bad_branch is not None
|
||||||
|
assert not _guarded_by_name_node(bad_branch, "method_nid"), (
|
||||||
|
"checker should reject method_nid used without a name_node guard"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Rust ──────────────────────────────────────────────────────────────────────
|
# ── Rust ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_rust_finds_struct():
|
def test_rust_finds_struct():
|
||||||
@@ -177,6 +336,29 @@ def test_rust_no_dangling_edges():
|
|||||||
assert e["source"] in node_ids
|
assert e["source"] in node_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_rust_trait_impl_emits_implements():
|
||||||
|
r = extract_rust(FIXTURES / "sample.rs")
|
||||||
|
assert ("DataProcessor", "Processor") in _edge_labels(r, "implements")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rust_supertrait_emits_inherits():
|
||||||
|
r = extract_rust(FIXTURES / "sample.rs")
|
||||||
|
assert ("Logger", "Processor") in _edge_labels(r, "inherits")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rust_struct_field_emits_field_context():
|
||||||
|
r = extract_rust(FIXTURES / "sample.rs")
|
||||||
|
assert ("DataProcessor", "Result") in _edge_labels(r, "references", "field")
|
||||||
|
assert ("DataProcessor", "DataProcessor") not in _edge_labels(r, "references", "field")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rust_method_parameter_return_and_generic_contexts():
|
||||||
|
r = extract_rust(FIXTURES / "sample.rs")
|
||||||
|
assert ("build", "DataProcessor") in _edge_labels(r, "references", "parameter_type")
|
||||||
|
assert ("build", "Result") in _edge_labels(r, "references", "return_type")
|
||||||
|
assert ("build", "DataProcessor") in _edge_labels(r, "references", "generic_arg")
|
||||||
|
|
||||||
|
|
||||||
def test_rust_no_cross_crate_spurious_edges():
|
def test_rust_no_cross_crate_spurious_edges():
|
||||||
"""Scoped calls (Type::method) and blocklisted names must not produce
|
"""Scoped calls (Type::method) and blocklisted names must not produce
|
||||||
INFERRED cross-crate calls edges (#908)."""
|
INFERRED cross-crate calls edges (#908)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user