From 7ebde5bc6c98f6820279d2e542b2e240f32f763e Mon Sep 17 00:00:00 2001 From: Andreas Ehrlich <3016354+ehrlichandreas@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:07:28 +0200 Subject: [PATCH 1/2] Go: package-level constants and variables become nodes The Go extractor dispatched on four node types: function_declaration, method_declaration, type_declaration and import_declaration. Go's grammar also has const_declaration and var_declaration, so a package-level constant never became a node, and reading one was not recorded as a relation. Measured on a 59-file Go project, asking for fifteen constants each read in exactly one function: none of the fifteen constants was a node, while all fifteen of the reading functions were. `graphify query ` answered "No matching nodes found" every time. The graph held the answer and offered no way in. Three parts: - const_declaration and var_declaration become nodes, marked with value_kind so later passes can find them, with a `contains` edge from the file. - Reading such a name inside a function body emits a `references` edge with context `value_use`. Without it the node exists but stays unreachable. Restricted to names declared as package-level values, so a local identifier produces nothing. - Names not declared in the file being extracted go to a new raw_value_refs bucket, bound afterwards by _bind_cross_file_value_refs. This mirrors raw_calls: an extractor sees one file and reports what it cannot resolve rather than guessing. Of the fifteen constants above, three were read from a sibling file, and those three stayed unreachable until this pass existed. Bound only for an unambiguous name, the same god-node guard the call resolver uses. raw_value_refs[].caller_nid is rewritten by both id_remap and sym_remap, as raw_calls[].caller_nid already is - left stale the edge would dangle on its source. After the change the same fifteen questions are answered 15/15. The graph grows from 327 to 395 nodes and 797 to 976 edges on that project, with no measurable change in build time (0.76 s both ways). tests/test_go_value_nodes.py covers both directions: a declared constant becomes a node, an unused one gets no reader, a cross-file read is bound, and a local variable neither becomes a node nor binds to one. Four of the six fail without this change. Co-Authored-By: Claude Opus 5 --- graphify/extract.py | 63 ++++++++++++++++++ graphify/extractors/go.py | 81 ++++++++++++++++++++++- tests/test_go_value_nodes.py | 121 +++++++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 tests/test_go_value_nodes.py diff --git a/graphify/extract.py b/graphify/extract.py index f68757a85f..8d0c196305 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2487,6 +2487,56 @@ def _augment_js_reexport_edges( # Header / implementation file-extension pairing for the decl/def class merge. + +def _bind_cross_file_value_refs( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Bind value references that cross a file boundary. + + An extractor sees one file. It can bind a constant only where that + constant is also declared, and reports the rest as ``raw_value_refs`` + rather than guessing - the same split ``raw_calls`` already uses for + calls. Here every file is available. + + Measured on a 59-file Go project: of fifteen constants each used in + exactly one function, three were used from a sibling file, and those + three stayed unreachable until this pass existed. + + Only for an unambiguous name, the same god-node guard the call + resolver applies: two same-named constants in two packages would + otherwise wire a caller to the wrong one. + """ + by_name: dict[str, list[str]] = {} + for n in all_nodes: + if n.get("value_kind"): + by_name.setdefault(str(n.get("label", "")), []).append(n["id"]) + if not by_name: + return + + have = {(e.get("source"), e.get("target")) for e in all_edges} + for result in per_file: + for rv in result.get("raw_value_refs") or []: + ids = by_name.get(rv.get("name", ""), []) + if len(ids) != 1: + continue + src, tgt = rv.get("caller_nid"), ids[0] + if not src or src == tgt or (src, tgt) in have: + continue + have.add((src, tgt)) + all_edges.append({ + "source": src, + "target": tgt, + "relation": "references", + "context": "value_use", + "confidence": "EXTRACTED", + "source_file": rv.get("source_file", ""), + "source_location": rv.get("source_location", ""), + "weight": 1.0, + }) + + def _merge_swift_extensions( per_file: list[dict], all_nodes: list[dict], @@ -6198,6 +6248,13 @@ def _portable_out_of_root_sf(p: Path) -> str: cn = rc.get("caller_nid") if cn in id_remap: rc["caller_nid"] = id_remap[cn] + # raw_value_refs carry the same kind of id and are consumed by + # _bind_cross_file_value_refs, so they need the same rewrite. + for result in per_file: + for rv in result.get("raw_value_refs") or []: + vn = rv.get("caller_nid") + if vn in id_remap: + rv["caller_nid"] = id_remap[vn] # swift_extensions[].nid is the same kind of id carrier as caller_nid # above (cache.py remaps both), consumed by _merge_swift_extensions far # below. Left stale it matches no node, so whether the extension merge @@ -6270,6 +6327,11 @@ def _portable_out_of_root_sf(p: Path) -> str: cn = rc.get("caller_nid") if cn in sym_remap: rc["caller_nid"] = sym_remap[cn] + for result in per_file: + for rv in result.get("raw_value_refs") or []: + vn = rv.get("caller_nid") + if vn in sym_remap: + rv["caller_nid"] = sym_remap[vn] # Same for swift_extensions[].nid (see the id_remap pass above). for result in per_file: for ext in result.get("swift_extensions", []) or []: @@ -6400,6 +6462,7 @@ def _learn(e: dict) -> None: # (src/) package root before the resolver/import-evidence passes run, so the # graph is identical regardless of scan root (#2072). _repoint_python_package_imports(paths, all_nodes, all_edges, root) + _bind_cross_file_value_refs(per_file, all_nodes, all_edges) _merge_swift_extensions(per_file, all_nodes, all_edges) _merge_csharp_partial_class_nodes(per_file, all_nodes, all_edges, paths, root) _disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root) diff --git a/graphify/extractors/go.py b/graphify/extractors/go.py index d8f203b77b..ccd4bf1a21 100644 --- a/graphify/extractors/go.py +++ b/graphify/extractors/go.py @@ -83,7 +83,7 @@ def _go_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[st _go_collect_type_refs(c, source, generic, out) def extract_go(path: Path) -> dict: - """Extract functions, methods, type declarations, and imports from a .go file.""" + """Extract functions, methods, types, values, and imports from a .go file.""" try: import tree_sitter_go as tsgo from tree_sitter import Language, Parser @@ -269,9 +269,51 @@ def symbol_nid(plain_nid: str, name: str) -> str: salt = hashlib.sha1(name.encode("utf-8"), usedforsecurity=False).hexdigest()[:6] return _make_id(plain_nid, salt) + # Package-level value names declared in this file. Kept separate from + # label_to_nid so the reference pass below binds only these and not + # every identifier it walks past. + value_nids: dict[str, str] = {} + + def add_value_decl(node, kind: str) -> None: + """Emit a node per name in a const_declaration or var_declaration. + + The dispatch below handled function_declaration, + method_declaration, type_declaration and import_declaration. Go's + grammar also has const_declaration and var_declaration, so a + package-level constant never became a node and nothing could + point at it. + + Measured on a 59-file Go project: of fifteen constants each used + in exactly one function, none was a node, while all fifteen of + those functions were. Asked "which function uses X", the graph + had the answer but no way in. + """ + for spec in node.children: + if spec.type not in ("const_spec", "var_spec"): + continue + line = spec.start_point[0] + 1 + for child in spec.children: + if child.type != "identifier": + continue + name = _read_text(child, source) + if not name or name == "_": + continue + nid = _make_id(pkg_scope, name) + add_node(nid, name, line) + for n in nodes: + if n["id"] == nid: + n["value_kind"] = kind + break + add_edge(file_nid, nid, "contains", line, context=kind) + value_nids[name] = nid + def walk(node) -> None: t = node.type + if t in ("const_declaration", "var_declaration"): + add_value_decl(node, "const" if t == "const_declaration" else "var") + return + if t == "function_declaration": name_node = node.child_by_field_name("name") if name_node: @@ -430,6 +472,8 @@ def walk(node) -> None: label_to_nid[normalised] = n["id"] seen_call_pairs: set[tuple[str, str]] = set() + seen_value_refs: set[tuple[str, str]] = set() + raw_value_refs: list[dict] = [] raw_calls: list[dict] = [] def walk_calls(node, caller_nid: str) -> None: @@ -493,6 +537,40 @@ def walk_calls(node, caller_nid: str) -> None: "source_file": str_path, "source_location": f"L{node.start_point[0] + 1}", }) + # Reading a value is a relation this extractor did not record. + # "Where is this constant used" asks for exactly that, and + # without the edge the node stays unreachable even once it + # exists. Restricted to names declared as package-level values, + # so a local identifier does not produce an edge. + if node.type == "identifier": + name = _read_text(node, source) + tgt = value_nids.get(name) + if tgt is None and name and name not in _LANGUAGE_BUILTIN_GLOBALS: + # Not declared in this file. It may be a constant from a + # sibling file or a local variable; only a pass that sees + # every file can tell, which is how cross-file calls are + # already resolved. See _bind_cross_file_value_refs. + raw_value_refs.append({ + "caller_nid": caller_nid, + "name": name, + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + }) + elif tgt and tgt != caller_nid: + pair = (caller_nid, tgt) + if pair not in seen_value_refs: + seen_value_refs.add(pair) + edges.append({ + "source": caller_nid, + "target": tgt, + "relation": "references", + "context": "value_use", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + for child in node.children: walk_calls(child, caller_nid) @@ -510,5 +588,6 @@ def walk_calls(node, caller_nid: str) -> None: "nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls, + "raw_value_refs": raw_value_refs, "go_imports": dict(go_imported_pkgs), } diff --git a/tests/test_go_value_nodes.py b/tests/test_go_value_nodes.py new file mode 100644 index 0000000000..decf9c8f6a --- /dev/null +++ b/tests/test_go_value_nodes.py @@ -0,0 +1,121 @@ +"""Go package-level constants and variables must be nodes with inbound edges. + +The Go extractor dispatched on four node types: function_declaration, +method_declaration, type_declaration and import_declaration. Go's grammar +also has const_declaration and var_declaration, so a package-level +constant never became a node, and reading one was not recorded as a +relation. + +Observed on a 59-file Go codebase, using fifteen constants each read in +exactly one function: none of the fifteen constants was a node, while all +fifteen of the reading functions were. Asked "which function uses X", the +graph held the answer and offered no way in - every query returned "No +matching nodes found". + +Three of those fifteen were read from a sibling file, which a per-file +extractor cannot bind. Those go through raw_value_refs and +_bind_cross_file_value_refs, the same split raw_calls already uses. +""" +import pytest + +from graphify.extract import extract + + +def _extract_go(tmp_path): + return extract(sorted(tmp_path.glob("*.go")), cache_root=tmp_path, parallel=False) + + +def _node_by_label(result, label): + for n in result["nodes"]: + if (n.get("label") or "").strip(".()") == label: + return n + return None + + +def _has_edge(result, src_label, tgt_label, relation): + src = _node_by_label(result, src_label) + tgt = _node_by_label(result, tgt_label) + if src is None or tgt is None: + return False + return any( + e.get("source") == src["id"] + and e.get("target") == tgt["id"] + and e.get("relation") == relation + for e in result["edges"] + ) + + +@pytest.fixture +def same_file(tmp_path): + (tmp_path / "sandbox.go").write_text( + "package runtime\n" + "\n" + "const (\n" + "\tsandboxProviderFD = 3\n" + "\tmaxOutput = 4096\n" + ")\n" + "\n" + "var defaultTimeout = 30\n" + "\n" + "func bubblewrapArguments() []string {\n" + "\t_ = sandboxProviderFD\n" + "\treturn nil\n" + "}\n" + ) + return _extract_go(tmp_path) + + +def test_constants_become_nodes(same_file): + for name in ("sandboxProviderFD", "maxOutput"): + node = _node_by_label(same_file, name) + assert node is not None, f"{name} is not a node" + assert node.get("value_kind") == "const" + + +def test_package_variables_become_nodes(same_file): + node = _node_by_label(same_file, "defaultTimeout") + assert node is not None + assert node.get("value_kind") == "var" + + +def test_reading_a_constant_is_an_edge(same_file): + assert _has_edge(same_file, "bubblewrapArguments", "sandboxProviderFD", "references") + + +def test_unused_constant_has_no_reader(same_file): + # maxOutput is declared and never read. A node, but nothing points at + # it: the pass must not invent an edge for every name it walks past. + assert not _has_edge(same_file, "bubblewrapArguments", "maxOutput", "references") + + +def test_constant_read_from_a_sibling_file(tmp_path): + (tmp_path / "sandbox.go").write_text( + "package runtime\n" + "\n" + "const sandboxProviderFD = 3\n" + ) + (tmp_path / "plan.go").write_text( + "package runtime\n" + "\n" + "func bubblewrapArguments() int {\n" + "\treturn sandboxProviderFD\n" + "}\n" + ) + result = _extract_go(tmp_path) + assert _has_edge(result, "bubblewrapArguments", "sandboxProviderFD", "references") + + +def test_local_variable_does_not_bind_to_a_constant(tmp_path): + # A local shadowing a package constant of another file must not wire + # the reader to it. The cross-file pass binds by name, so the guard + # is that only declared package-level values are candidates. + (tmp_path / "a.go").write_text( + "package runtime\n" + "\n" + "func caller() int {\n" + "\tnotAConstant := 7\n" + "\treturn notAConstant\n" + "}\n" + ) + result = _extract_go(tmp_path) + assert _node_by_label(result, "notAConstant") is None From b84dc1412301ee93327cdd7c7014805c7566956f Mon Sep 17 00:00:00 2001 From: Andreas Ehrlich <3016354+ehrlichandreas@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:46:24 +0200 Subject: [PATCH 2/2] Java: fields become nodes, via a new LanguageConfig.value_types Same gap as the Go one in the previous commit, in the shared engine rather than a bespoke extractor. The dispatch knew classes, functions, imports and calls. A Java constant is a field_declaration and none of those, so it never became a node and reading one was not recorded as a relation. Measured on a 14-file Java project using five constants each read in exactly one method: none of the five was a node, while all five reading methods were. `graphify query` answered 1/5; after this change, 5/5. LanguageConfig gains value_types and value_kind. Both default to empty, so every other language behaves exactly as before and opts in with one line when it wants to. Only _JAVA_CONFIG sets them here (field_declaration), because that is the one language this was measured on. The engine emits the same two things the Go extractor does: a node per declared name, marked value_kind, and a `references` edge with context `value_use` for a read inside a method body. Names not declared in the file go to raw_value_refs and are bound by _bind_cross_file_value_refs, which is language-agnostic and already in place from the Go commit. The value branch deliberately does not return. A field_declaration also carries its type, which the field-type-reference pass and the Java receiver-type table read from the same subtree; returning there broke eight Java tests that have nothing to do with values. Full suite: 15 pre-existing failures before and after (missing optional tree-sitter grammars), no new ones. tests/test_java_value_nodes.py adds five cases, three of which fail without this change, including one that pins the opt-in: a Python module constant must stay out of the graph. Co-Authored-By: Claude Opus 5 --- graphify/extract.py | 4 ++ graphify/extractors/engine.py | 73 ++++++++++++++++++++++- graphify/extractors/models.py | 6 ++ tests/test_java_value_nodes.py | 103 +++++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 tests/test_java_value_nodes.py diff --git a/graphify/extract.py b/graphify/extract.py index 8d0c196305..ae9304f9ed 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -852,6 +852,10 @@ def _get_c_func_name(node, source: bytes) -> str | None: }), function_types=frozenset({"method_declaration", "constructor_declaration"}), import_types=frozenset({"import_declaration"}), + # A Java constant is a field_declaration, which none of the sets above + # covers. See the value_types branch in engine.walk. + value_types=frozenset({"field_declaration"}), + value_kind="field", # object_creation_expression (`new Foo(...)`) is handled by a dedicated Java # branch in walk_calls below — its callee is in the `type` field, not `name`. call_types=frozenset({"method_invocation", "object_creation_expression"}), diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index e883747784..64964a2de9 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2920,6 +2920,13 @@ def _extract_generic( nodes: list[dict] = [] edges: list[dict] = [] seen_ids: set[str] = set() + # Package/class-level value names declared in this file, and the reads + # this file cannot resolve. Same split as raw_calls: one file is not + # enough to bind a name declared in a sibling. + value_nids: dict[str, str] = {} + raw_value_refs: list[dict] = [] + seen_value_refs: set[tuple[str, str]] = set() + namespace_stack: list[str] = [] # Ruby only: enclosing module/class segments, so `module Foo::Bar` (compact) # and `module Foo; module Bar` (nested) label the same node `Foo::Bar` and @@ -3099,6 +3106,42 @@ def walk(node, parent_class_nid: str | None = None) -> None: walk(child, parent_class_nid) return + # Value declarations: a name that is read rather than called. + # + # The dispatch below knows classes, functions, imports and calls. A + # Java constant is none of those, so it never became a node and + # nothing could point at it. Measured on a 14-file Java project + # using five constants each read in exactly one method: none of the + # five was a node, while all five reading methods were. + # + # Opt-in per language: value_types is empty unless a config sets it, + # which is what every language did implicitly before. + if config.value_types and t in config.value_types: + for decl in node.children: + if decl.type not in ("variable_declarator", "identifier"): + continue + nm = decl.child_by_field_name("name") if decl.type == "variable_declarator" else decl + if nm is None: + continue + vname = _read_text(nm, source) + if not vname or vname == "_": + continue + vline = decl.start_point[0] + 1 + vnid = _make_id(stem, vname) + add_node(vnid, vname, vline) + for n in nodes: + if n["id"] == vnid: + n["value_kind"] = config.value_kind + break + add_edge(parent_class_nid or file_nid, vnid, "contains", vline, + context=config.value_kind) + value_nids[vname] = vnid + # Deliberately no return: a field_declaration also carries its + # type, which the field-type-reference pass and the Java + # receiver-type table read from the same subtree. Returning + # here broke eight Java tests that had nothing to do with + # values. + # Class types if t in config.class_types: # Resolve class name @@ -5044,6 +5087,33 @@ def walk_calls( receiver_types: dict[str, str] | tuple | None = None, extra_locals: frozenset[str] = frozenset(), ) -> None: + # A read is a relation this engine did not record. "Where is this + # constant used" asks for exactly that, and without the edge the + # node exists but stays unreachable. Only names declared as values, + # so an ordinary local identifier produces nothing. + if config.value_types and node.type == "identifier": + vname = _read_text(node, source) + vtgt = value_nids.get(vname) + if vtgt is None and vname: + raw_value_refs.append({ + "caller_nid": caller_nid, + "name": vname, + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + }) + elif vtgt and vtgt != caller_nid and (caller_nid, vtgt) not in seen_value_refs: + seen_value_refs.add((caller_nid, vtgt)) + edges.append({ + "source": caller_nid, + "target": vtgt, + "relation": "references", + "context": "value_use", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + if node.type in config.function_boundary_types: # JS/TS: an inline/returned closure not separately tracked in # function_bodies would otherwise drop its calls at this boundary. @@ -5908,7 +5978,8 @@ def _scan_js_module_dispatch(n) -> None: # fold them in so the cross-file resolver sees them (#1668). if _ruby_mixin_calls: raw_calls.extend(_ruby_mixin_calls) - result = {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls} + result = {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls, + "raw_value_refs": raw_value_refs} # #2551: the parser recovered from syntax errors, so extraction may be # partial (in the worst case, nothing but the file node). Record the first # error's line so extract() can warn instead of reporting silent success. diff --git a/graphify/extractors/models.py b/graphify/extractors/models.py index bd8677aec1..1fe941868d 100644 --- a/graphify/extractors/models.py +++ b/graphify/extractors/models.py @@ -20,6 +20,12 @@ class LanguageConfig: import_types: frozenset = frozenset() call_types: frozenset = frozenset() static_prop_types: frozenset = frozenset() + # Declarations that introduce a named value rather than a callable: a Java + # field, a Rust const_item. Empty means the language opts out, which is + # what every language did implicitly before the field existed. + value_types: frozenset = frozenset() + # What the value_kind marker on those nodes says. + value_kind: str = "value" helper_fn_names: frozenset = frozenset() container_bind_methods: frozenset = frozenset() event_listener_properties: frozenset = frozenset() diff --git a/tests/test_java_value_nodes.py b/tests/test_java_value_nodes.py new file mode 100644 index 0000000000..c1e2bce92c --- /dev/null +++ b/tests/test_java_value_nodes.py @@ -0,0 +1,103 @@ +"""Java fields must be nodes with inbound edges, via LanguageConfig.value_types. + +The shared engine dispatched on classes, functions, imports and calls. A +Java constant is a field_declaration and none of those, so it never +became a node and reading one was not recorded as a relation. + +Observed on a 14-file Java codebase using five constants each read in +exactly one method: none of the five was a node, while all five reading +methods were. `graphify query ` answered 1/5; after this +change, 5/5. + +value_types is opt-in per language and empty everywhere else, which is +what every language did implicitly before the field existed. Go declares +its own values in extractors/go.py because it does not use this engine. +""" +import pytest + +from graphify.extract import extract + + +def _extract(tmp_path, glob): + return extract(sorted(tmp_path.glob(glob)), cache_root=tmp_path, parallel=False) + + +def _node_by_label(result, label): + for n in result["nodes"]: + if (n.get("label") or "").strip(".()") == label: + return n + return None + + +def _has_edge(result, src_label, tgt_label, relation): + src = _node_by_label(result, src_label) + tgt = _node_by_label(result, tgt_label) + if src is None or tgt is None: + return False + return any( + e.get("source") == src["id"] + and e.get("target") == tgt["id"] + and e.get("relation") == relation + for e in result["edges"] + ) + + +@pytest.fixture +def gilded(tmp_path): + (tmp_path / "GildedRose.java").write_text( + "package shop;\n" + "\n" + "public class GildedRose {\n" + " private static final int MAX_QUALITY = 50;\n" + " private static final int UNUSED_LIMIT = 99;\n" + "\n" + " private int clamp(int quality) {\n" + " return Math.min(MAX_QUALITY, quality);\n" + " }\n" + "}\n" + ) + return _extract(tmp_path, "*.java") + + +def test_fields_become_nodes(gilded): + node = _node_by_label(gilded, "MAX_QUALITY") + assert node is not None, "MAX_QUALITY is not a node" + assert node.get("value_kind") == "field" + + +def test_reading_a_field_is_an_edge(gilded): + assert _has_edge(gilded, "clamp", "MAX_QUALITY", "references") + + +def test_unread_field_has_no_reader(gilded): + assert not _has_edge(gilded, "clamp", "UNUSED_LIMIT", "references") + + +def test_field_read_from_a_sibling_file(tmp_path): + (tmp_path / "Limits.java").write_text( + "package shop;\n" + "\n" + "public class Limits {\n" + " public static final int MAX_QUALITY = 50;\n" + "}\n" + ) + (tmp_path / "Rose.java").write_text( + "package shop;\n" + "\n" + "public class Rose {\n" + " private int clamp(int quality) {\n" + " return Math.min(MAX_QUALITY, quality);\n" + " }\n" + "}\n" + ) + result = _extract(tmp_path, "*.java") + assert _has_edge(result, "clamp", "MAX_QUALITY", "references") + + +def test_a_language_without_value_types_is_unchanged(tmp_path): + # Python does not set value_types, so a module-level constant stays out + # of the graph exactly as before. The field is opt-in on purpose: every + # language behaved this way until one of them asked for more. + (tmp_path / "m.py").write_text("LIMIT = 50\n\n\ndef clamp(q):\n return min(LIMIT, q)\n") + result = _extract(tmp_path, "*.py") + assert _node_by_label(result, "LIMIT") is None