diff --git a/CHANGELOG.md b/CHANGELOG.md index f04448f..5a89a8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,17 @@ - Pin `tree-sitter` to the compatible 0.25 series, with large-file native-crash regressions for Go, Rust, Solidity, and TypeScript (#61, #62). -- Detect Solidity entrypoints from parser metadata, exclude interfaces, suppress - overridden base implementations, and expose visibility/mutability (#57). +- Detect Solidity entrypoints from parser metadata, exclude interfaces, retain + overridden base implementations with `solidity_overridden_by`, and expose + visibility/mutability (#57). - Resolve straightforward constructed TypeScript interface receivers and document call-reachability versus taint limitations (#30). - Add PostgreSQL-oriented SQL schema, table, view, function, procedure, and dependency extraction (#59). - Add stable `.trailmark/links.toml` configuration for cross-language, FFI, RPC, - and external/binary graph links (#58). + and external/binary graph links with per-endpoint external flags (#58). +- Materialize repository links, unresolved-call proxies, and `TYPE_USES` edges + for single-language directory parses as well as polyglot parses. - Support C# file-scoped namespaces (#63). - Document grammar caching and TLS-inspection/offline installation (#39). - Add wheel/sdist installed-package smoke tests across all supported languages. diff --git a/README.md b/README.md index d600aa8..a26f396 100644 --- a/README.md +++ b/README.md @@ -205,9 +205,10 @@ classDiagram Unresolved calls are materialized as proxy nodes such as `proxy.unresolved:` so traversal results can show where source -analysis lost resolution instead of silently dropping that edge. Binary -analysis support imports external JSON call graphs; Trailmark does not -disassemble executables itself. +analysis lost resolution instead of silently dropping that edge. Directory +parses, including single-language parses, also apply repository links and +materialize `TYPE_USES` edges. Binary analysis support imports external JSON +call graphs; Trailmark does not disassemble executables itself. ### Example Graph @@ -402,11 +403,13 @@ Later entries override earlier ones when two rules tag the same node, so place b See [docs/entrypoint-patterns.md](docs/entrypoint-patterns.md) for the full reference, including frameworks not yet implemented (Express / Koa / Fastify, Laravel, Cobra, axum, warp, clap, and others) with grep-ready patterns contributors can use to add new detectors. Solidity detection uses parser metadata rather than signature regexes. Interface -declarations are excluded and a derived override suppresses the matching base -implementation. Concrete `public` and `external` functions remain entrypoints, -including `view` and `pure` functions; their `solidity_visibility` and -`solidity_mutability` attributes are returned by `attack_surface()` so callers -can distinguish read-only exposure. `attack_surface()` includes parser-specific +declarations are excluded, and derived overrides annotate matching base +implementations with `solidity_overridden_by` instead of removing the base +entrypoint. Concrete `public` and `external` functions remain entrypoints, +including `view` and `pure` functions; their `solidity_visibility`, +`solidity_mutability`, and override attributes are returned by +`attack_surface()` so callers can distinguish read-only exposure and filter +overridden base implementations. `attack_surface()` includes parser-specific entrypoint attributes when they are attached to the underlying graph node. ### Cross-language and external links @@ -426,13 +429,14 @@ description = "JSON-RPC eth_call" [[link]] source = "backend:notify" target = "payments-webhook" -external = true # required when either endpoint is unresolved +target_external = true # required because target is unresolved ``` References may be exact node IDs or unique names/suffixes. Ambiguous references, unknown internal endpoints, invalid enum values, and malformed TOML raise -`ValueError`. Setting `external = true` explicitly permits unresolved endpoints -and creates proxy nodes. This file is a stable public configuration interface. +`ValueError`. Setting `source_external = true` or `target_external = true` +explicitly permits that unresolved endpoint and creates a proxy node. This file +is a stable public configuration interface. ### Analysis limitations diff --git a/src/trailmark/analysis/entrypoints.py b/src/trailmark/analysis/entrypoints.py index 08ffaa9..8514699 100644 --- a/src/trailmark/analysis/entrypoints.py +++ b/src/trailmark/analysis/entrypoints.py @@ -45,6 +45,7 @@ from __future__ import annotations +import dataclasses import re import tomllib from pathlib import Path @@ -58,7 +59,7 @@ ) from trailmark.models.edges import EdgeKind from trailmark.models.graph import CodeGraph -from trailmark.models.nodes import CodeUnit +from trailmark.models.nodes import CodeUnit, NodeKind OVERRIDE_FILE = ".trailmark/entrypoints.toml" @@ -285,7 +286,7 @@ def _detect_framework_entrypoints(graph: CodeGraph) -> dict[str, EntrypointTag]: tag = _detect_for_unit(cache, unit, path) if tag is not None: result[node_id] = tag - _suppress_overridden_solidity_entrypoints(graph, result) + _annotate_overridden_solidity_entrypoints(graph, result) return result @@ -448,16 +449,16 @@ def _detect_solidity( return None -def _suppress_overridden_solidity_entrypoints( +def _annotate_overridden_solidity_entrypoints( graph: CodeGraph, detected: dict[str, EntrypointTag], ) -> None: - """Remove base implementations shadowed by a derived Solidity contract.""" + """Annotate base implementations shadowed by a derived Solidity contract.""" containers: dict[str, str] = {} for edge in graph.edges: if edge.kind == EdgeKind.CONTAINS and edge.target_id in graph.nodes: unit = graph.nodes[edge.target_id] - if unit.kind.value == "method" and unit.location.file_path.endswith(".sol"): + if unit.kind is NodeKind.METHOD and unit.location.file_path.endswith(".sol"): containers[edge.target_id] = edge.source_id methods: dict[tuple[str, tuple[str, ...]], dict[str, str]] = {} @@ -487,10 +488,23 @@ def _suppress_overridden_solidity_entrypoints( visited.add(base) base_method = by_contract.get(base) if base_method is not None: - detected.pop(base_method, None) + _append_overridden_by(graph, base_method, method_id) stack.extend(bases.get(base, ())) +def _append_overridden_by(graph: CodeGraph, base_method: str, derived_method: str) -> None: + unit = graph.nodes.get(base_method) + if unit is None: + return + attributes = dict(unit.attributes) + current = attributes.get("solidity_overridden_by") + current_text = current if isinstance(current, str) else "" + existing = {item.strip() for item in current_text.split(",") if item.strip()} + existing.add(derived_method) + attributes["solidity_overridden_by"] = ",".join(sorted(existing)) + graph.nodes[base_method] = dataclasses.replace(unit, attributes=tuple(attributes.items())) + + def _detect_js_ts( cache: _SourceCache, unit: CodeUnit, diff --git a/src/trailmark/analysis/links.py b/src/trailmark/analysis/links.py index a59389b..a24a87d 100644 --- a/src/trailmark/analysis/links.py +++ b/src/trailmark/analysis/links.py @@ -18,8 +18,8 @@ def apply_repository_links(graph: CodeGraph, root_path: str) -> None: """Add links declared in ``.trailmark/links.toml`` below ``root_path``. Invalid configuration is rejected rather than silently weakening the - resulting graph. Unresolved endpoints are accepted only when the entry - explicitly sets ``external = true``. + resulting graph. Unresolved endpoints are accepted only when explicitly + marked with ``source_external`` or ``target_external``. """ config_path = Path(root_path).resolve() / LINKS_FILE if not config_path.is_file(): @@ -30,6 +30,7 @@ def apply_repository_links(graph: CodeGraph, root_path: str) -> None: msg = f"Invalid {LINKS_FILE}: {exc}" raise ValueError(msg) from exc + _validate_top_level(data) entries = data.get("link", []) if not isinstance(entries, list): msg = f"Invalid {LINKS_FILE}: 'link' must be an array of tables" @@ -42,14 +43,18 @@ def _apply_link(graph: CodeGraph, raw: object, index: int, config_path: Path) -> if not isinstance(raw, dict): _invalid(index, "entry must be a table") entry = cast("dict[str, Any]", raw) + _validate_link_keys(entry, index) source_ref = _required_string(entry, "source", index) target_ref = _required_string(entry, "target", index) - external = entry.get("external", False) - if not isinstance(external, bool): - _invalid(index, "'external' must be a boolean") + source_external = _optional_bool(entry, "source_external", index) + target_external = _optional_bool(entry, "target_external", index) - source_id = _resolve_endpoint(graph, source_ref, external, index, config_path) - target_id = _resolve_endpoint(graph, target_ref, external, index, config_path) + source_id = _resolve_endpoint( + graph, source_ref, source_external, "source_external", index, config_path + ) + target_id = _resolve_endpoint( + graph, target_ref, target_external, "target_external", index, config_path + ) kind = _edge_kind(entry.get("kind", "calls"), index) confidence = _edge_confidence(entry.get("confidence", "inferred"), index) description = entry.get("description") @@ -70,6 +75,27 @@ def _apply_link(graph: CodeGraph, raw: object, index: int, config_path: Path) -> graph.edges.append(edge) +def _validate_top_level(data: dict[str, object]) -> None: + unknown = sorted(key for key in data if key != "link") + if unknown: + _invalid(0, f"unknown top-level key {unknown[0]!r}; only 'link' is valid") + + +def _validate_link_keys(entry: dict[str, Any], index: int) -> None: + allowed = { + "source", + "target", + "kind", + "confidence", + "description", + "source_external", + "target_external", + } + unknown = sorted(key for key in entry if key not in allowed) + if unknown: + _invalid(index, f"unknown key {unknown[0]!r}") + + def _required_string(entry: dict[str, Any], key: str, index: int) -> str: value = entry.get(key) if not isinstance(value, str) or not value.strip(): @@ -77,10 +103,18 @@ def _required_string(entry: dict[str, Any], key: str, index: int) -> str: return value.strip() +def _optional_bool(entry: dict[str, Any], key: str, index: int) -> bool: + value = entry.get(key, False) + if not isinstance(value, bool): + _invalid(index, f"'{key}' must be a boolean") + return value + + def _resolve_endpoint( graph: CodeGraph, reference: str, external: bool, + flag_name: str, index: int, config_path: Path, ) -> str: @@ -98,7 +132,10 @@ def _resolve_endpoint( if len(matches) > 1: _invalid(index, f"endpoint {reference!r} is ambiguous: {', '.join(sorted(matches))}") if not external: - _invalid(index, f"endpoint {reference!r} does not exist (set external = true to proxy it)") + _invalid( + index, + f"endpoint {reference!r} does not exist (set {flag_name} = true to proxy it)", + ) return _add_external_proxy(graph, reference, config_path) diff --git a/src/trailmark/parse.py b/src/trailmark/parse.py index e200e29..60f1248 100644 --- a/src/trailmark/parse.py +++ b/src/trailmark/parse.py @@ -199,7 +199,7 @@ def _resolve_directory_languages(path: str, spec: str) -> list[str]: def _parse_and_merge(path: str, languages: list[str]) -> CodeGraph: """Parse ``path`` with each language parser and merge into one graph.""" if len(languages) == 1: - # Preserves pre-polyglot behavior exactly for the common case. + # Single-language directory parses also apply repository links and proxies. graph = _get_parser(languages[0]).parse_directory(path) apply_repository_links(graph, path) return ensure_proxy_nodes(graph) diff --git a/src/trailmark/parsers/sql/parser.py b/src/trailmark/parsers/sql/parser.py index aaa8e9a..e88ab3d 100644 --- a/src/trailmark/parsers/sql/parser.py +++ b/src/trailmark/parsers/sql/parser.py @@ -112,6 +112,8 @@ def _extract_declaration( schema_id = f"{module_id}:{schema}" if schema_id in graph.nodes: container_id = schema_id + else: + unit_id = _disambiguate_sql_id(graph, unit_id, kind) parameters = ( _routine_parameters(node) if kind in {NodeKind.FUNCTION, NodeKind.PROCEDURE} else () @@ -189,7 +191,7 @@ def _add_dependencies( continue if not _is_relation_reference(child): continue - target_id = _sql_id(module_id, *reference) + target_id = _sql_reference_id(graph, module_id, *reference) if target_id == source_id or target_id in seen: continue seen.add(target_id) @@ -222,6 +224,23 @@ def _sql_id(module_id: str, schema: str | None, name: str) -> str: return f"{module_id}:{qualified}" +def _disambiguate_sql_id(graph: CodeGraph, unit_id: str, kind: NodeKind) -> str: + existing = graph.nodes.get(unit_id) + if existing is None or existing.kind is kind: + return unit_id + return f"{unit_id}#{kind.value}" + + +def _sql_reference_id(graph: CodeGraph, module_id: str, schema: str | None, name: str) -> str: + unit_id = _sql_id(module_id, schema, name) + if graph.nodes.get(unit_id, None) is not None: + for kind in (NodeKind.TABLE, NodeKind.VIEW): + disambiguated = f"{unit_id}#{kind.value}" + if disambiguated in graph.nodes: + return disambiguated + return unit_id + + def _extract_procedures( source: bytes, file_path: str, @@ -230,7 +249,8 @@ def _extract_procedures( ) -> None: """Recover PostgreSQL procedures unsupported by the permissive grammar.""" text = source.decode("utf-8", errors="replace") - matches = list(_PROCEDURE.finditer(text)) + scan_text = _mask_sql_comments(text) + matches = list(_PROCEDURE.finditer(scan_text)) for match_index, match in enumerate(matches): schema = match.group("schema") name = match.group("name") @@ -252,13 +272,15 @@ def _extract_procedures( next_procedure = ( matches[match_index + 1].start() if match_index + 1 < len(matches) else len(text) ) - next_declaration = _NEXT_CREATE.search(text, match.end()) + next_declaration = _NEXT_CREATE.search(scan_text, match.end()) end = min( next_procedure, next_declaration.start() if next_declaration is not None else len(text), ) - for reference in _RELATION_REFERENCE.finditer(text, match.end(), end): - target_id = _sql_id(module_id, reference.group("schema"), reference.group("name")) + for reference in _RELATION_REFERENCE.finditer(scan_text, match.end(), end): + target_id = _sql_reference_id( + graph, module_id, reference.group("schema"), reference.group("name") + ) graph.edges.append( CodeEdge( source_id=unit_id, @@ -270,6 +292,60 @@ def _extract_procedures( ) +def _mask_sql_comments(text: str) -> str: + chars = list(text) + index = 0 + dollar_quote: str | None = None + while index < len(chars): + if dollar_quote is not None: + index, dollar_quote = _advance_dollar_quote(text, index, dollar_quote) + elif _starts_line_comment(text, index): + index = _mask_until_line_end(chars, index) + elif text.startswith("/*", index): + index = _mask_block_comment(chars, index) + else: + dollar_quote = _dollar_quote_tag(text, index) + index += len(dollar_quote) if dollar_quote else 1 + return "".join(chars) + + +def _advance_dollar_quote(text: str, index: int, tag: str) -> tuple[int, str | None]: + if text.startswith(tag, index): + return index + len(tag), None + return index + 1, tag + + +def _starts_line_comment(text: str, index: int) -> bool: + return text.startswith("--", index) + + +def _mask_until_line_end(chars: list[str], index: int) -> int: + while index < len(chars) and chars[index] != "\n": + chars[index] = " " + index += 1 + return index + + +def _mask_block_comment(chars: list[str], index: int) -> int: + chars[index] = " " + chars[index + 1] = " " + index += 2 + while index < len(chars): + if index + 1 < len(chars) and chars[index] == "*" and chars[index + 1] == "/": + chars[index] = " " + chars[index + 1] = " " + return index + 2 + if chars[index] != "\n": + chars[index] = " " + index += 1 + return index + + +def _dollar_quote_tag(text: str, index: int) -> str | None: + match = re.match(r"\$[A-Za-z_][A-Za-z_0-9]*\$|\$\$", text[index:]) + return match.group(0) if match else None + + def _materialize_sql_dependency_targets(graph: CodeGraph, file_path: str) -> None: for edge in graph.edges: if edge.kind != EdgeKind.CORRESPONDS_TO or edge.target_id in graph.nodes: diff --git a/src/trailmark/parsers/typescript/parser.py b/src/trailmark/parsers/typescript/parser.py index d4f3ea6..ea1c490 100644 --- a/src/trailmark/parsers/typescript/parser.py +++ b/src/trailmark/parsers/typescript/parser.py @@ -2,7 +2,6 @@ from __future__ import annotations -import re from pathlib import Path from tree_sitter import Node, Parser @@ -47,6 +46,9 @@ _FUNCTION_DECL_TYPES = frozenset({"function_declaration", "generator_function_declaration"}) _FUNCTION_EXPR_TYPES = frozenset({"arrow_function", "function", "function_expression"}) +_FUNCTION_SCOPE_TYPES = ( + _FUNCTION_DECL_TYPES | _FUNCTION_EXPR_TYPES | frozenset({"method_definition"}) +) _EXTENSIONS = (".ts", ".tsx") @@ -863,29 +865,70 @@ def _resolve_concrete_receiver(call_name: str, call_node: Node, module_id: str) receiver, method = call_name.split(".", 1) if receiver == "this": return None - scope = call_node.parent - while scope is not None and scope.type not in { - "function_declaration", - "function_expression", - "arrow_function", - "method_definition", - }: - scope = scope.parent + scope = _nearest_function_scope(call_node) if scope is None: return None - prefix_length = max(0, call_node.start_byte - scope.start_byte) - prefix = node_text(scope).encode()[:prefix_length].decode("utf-8", errors="ignore") - pattern = re.compile( - rf"\b{re.escape(receiver)}\b(?:\s*:\s*[^=;]+)?\s*=\s*new\s+" - r"(?P[A-Za-z_$][\w$]*)\s*\(", - ) - matches = list(pattern.finditer(prefix)) - if not matches: + concrete_class = _latest_receiver_assignment(scope, call_node, receiver) + if concrete_class is None: return None - concrete_class = matches[-1].group("class") return f"{module_id}:{concrete_class}.{method}" +def _nearest_function_scope(node: Node) -> Node | None: + current = node.parent + while current is not None and current.type not in _FUNCTION_SCOPE_TYPES: + current = current.parent + return current + + +def _latest_receiver_assignment(scope: Node, call_node: Node, receiver: str) -> str | None: + latest: tuple[int, str] | None = None + for node in _walk_nodes(scope): + if node.start_byte >= call_node.start_byte: + continue + if _nearest_function_scope(node) != scope: + continue + concrete_class = _receiver_assignment_class(node, receiver) + if concrete_class is not None: + latest = (node.start_byte, concrete_class) + return latest[1] if latest is not None else None + + +def _receiver_assignment_class(node: Node, receiver: str) -> str | None: + if node.type == "variable_declarator": + assigned = node.child_by_field_name("name") + value = node.child_by_field_name("value") + elif node.type == "assignment_expression": + assigned = node.child_by_field_name("left") + value = node.child_by_field_name("right") + else: + return None + if assigned is None or value is None: + return None + if assigned.type != "identifier" or node_text(assigned) != receiver: + return None + return _new_expression_class(value) + + +def _new_expression_class(node: Node) -> str | None: + if node.type != "new_expression": + return None + for child in node.named_children: + if child.type == "identifier": + return node_text(child) + return None + + +def _walk_nodes(root: Node) -> list[Node]: + result: list[Node] = [] + stack = list(reversed(root.named_children)) + while stack: + node = stack.pop() + result.append(node) + stack.extend(reversed(node.named_children)) + return result + + def _call_confidence(call_name: str) -> EdgeConfidence: """Determine confidence level for a call.""" if "." not in call_name: diff --git a/src/trailmark/query/api.py b/src/trailmark/query/api.py index 2d90b05..e228762 100644 --- a/src/trailmark/query/api.py +++ b/src/trailmark/query/api.py @@ -205,7 +205,7 @@ def attack_surface(self) -> list[dict[str, Any]]: "asset_value": tag.asset_value.value, "description": tag.description, } - unit = self._store._graph.nodes.get(node_id) # noqa: SLF001 + unit = self._store.unit(node_id) if unit is not None and unit.attributes: item["attributes"] = dict(unit.attributes) result.append(item) diff --git a/src/trailmark/storage/graph_store.py b/src/trailmark/storage/graph_store.py index 766fd10..125f8f0 100644 --- a/src/trailmark/storage/graph_store.py +++ b/src/trailmark/storage/graph_store.py @@ -96,6 +96,10 @@ def _idx(self, node_id: str) -> int | None: def _node(self, node_id: str) -> CodeUnit | None: return self._graph.nodes.get(node_id) + def unit(self, node_id: str) -> CodeUnit | None: + """Return the graph unit for ``node_id`` if it exists.""" + return self._node(node_id) + def callers_of(self, node_id: str) -> list[CodeUnit]: """Return all nodes that call the given node.""" target_idx = self._idx(node_id) diff --git a/tests/test_detector_tags.py b/tests/test_detector_tags.py index eeb72a8..dd0da94 100644 --- a/tests/test_detector_tags.py +++ b/tests/test_detector_tags.py @@ -208,11 +208,12 @@ def test_mutability_is_preserved_on_entrypoint_node(self, tmp_path: Path) -> Non "solidity_mutability": "view", "solidity_visibility": "external", } - unit = engine._store._graph.nodes["C:C.inspect"] # noqa: SLF001 + unit = engine._store.unit("C:C.inspect") + assert unit is not None assert ("solidity_visibility", "external") in unit.attributes assert ("solidity_mutability", "view") in unit.attributes - def test_derived_override_suppresses_base_entrypoint(self, tmp_path: Path) -> None: + def test_derived_override_annotates_base_entrypoint(self, tmp_path: Path) -> None: (tmp_path / "C.sol").write_text( "contract Base { function act(uint x) public virtual {} }\n" "contract Derived is Base { function act(uint x) public override {} }\n", @@ -220,7 +221,10 @@ def test_derived_override_suppresses_base_entrypoint(self, tmp_path: Path) -> No engine = QueryEngine.from_directory(str(tmp_path), language="solidity") ids = {entry["node_id"] for entry in engine.attack_surface()} assert "C:Derived.act" in ids - assert "C:Base.act" not in ids + assert "C:Base.act" in ids + base = engine._store.unit("C:Base.act") + assert base is not None + assert ("solidity_overridden_by", "C:Derived.act") in base.attributes class TestNextJsDetectorTags: diff --git a/tests/test_dispatch_resolution.py b/tests/test_dispatch_resolution.py index 75f6d58..41449c0 100644 --- a/tests/test_dispatch_resolution.py +++ b/tests/test_dispatch_resolution.py @@ -36,3 +36,28 @@ def test_dynamic_property_dispatch_remains_unresolved(tmp_path: Path) -> None: edge.source_id == "dynamic:entry" and "HandlerImpl" in edge.target_id for edge in graph.edges ) + + +def test_nested_arrow_assignment_does_not_resolve_outer_call(tmp_path: Path) -> None: + source = tmp_path / "handler.ts" + source.write_text( + "interface Handler { process(): void }\n" + "class HandlerImpl implements Handler { process(): void {} }\n" + "class OtherImpl implements Handler { process(): void {} }\n" + "function entry(): void {\n" + " let handler: Handler = new HandlerImpl();\n" + " const replace = () => { handler = new OtherImpl(); };\n" + " handler.process();\n" + "}\n", + ) + graph = parse_file(str(source), "typescript") + + edge = next( + edge + for edge in graph.edges + if edge.source_id == "handler:entry" + and edge.kind == EdgeKind.CALLS + and edge.target_id.endswith(".process") + ) + assert edge.target_id == "handler:HandlerImpl.process" + assert edge.confidence == EdgeConfidence.INFERRED diff --git a/tests/test_readme.py b/tests/test_readme.py index 48b3240..4edf310 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -204,6 +204,14 @@ def test_clear_annotations_signature_matches_readme(self) -> None: assert params["kind"].default is None +class TestConfigurationDocs: + def test_links_toml_uses_per_endpoint_external_flags(self, readme_text: str) -> None: + """README documents per-endpoint external link flags, not entry-level external.""" + assert "target_external = true" in readme_text + assert "source_external = true" in readme_text + assert re.search(r"(? None: """README: default language is Python.""" diff --git a/tests/test_repository_links.py b/tests/test_repository_links.py index a46b537..8458043 100644 --- a/tests/test_repository_links.py +++ b/tests/test_repository_links.py @@ -41,22 +41,46 @@ def test_external_link_requires_opt_in_and_creates_proxy(tmp_path: Path) -> None _write_sources(tmp_path) config = tmp_path / ".trailmark" / "links.toml" config.write_text('[[link]]\nsource = "caller:invoke"\ntarget = "rpc:transfer"\n') - with pytest.raises(ValueError, match="external = true"): + with pytest.raises(ValueError, match="target_external = true"): parse_directory(str(tmp_path), "python") config.write_text( - '[[link]]\nsource = "caller:invoke"\ntarget = "rpc:transfer"\nexternal = true\n', + '[[link]]\nsource = "caller:invoke"\ntarget = "rpc:transfer"\ntarget_external = true\n', ) graph = parse_directory(str(tmp_path), "python") proxy = graph.nodes["proxy.external:rpc:transfer"] assert proxy.origin == NodeOrigin.PROXY + assert "caller:invoke" in graph.nodes + + +def test_per_endpoint_external_keeps_unflagged_endpoint_strict(tmp_path: Path) -> None: + _write_sources(tmp_path) + (tmp_path / ".trailmark" / "links.toml").write_text( + '[[link]]\nsource = "caller:typo"\ntarget = "rpc:transfer"\ntarget_external = true\n', + ) + + with pytest.raises(ValueError, match="source_external = true"): + parse_directory(str(tmp_path), "python") + + +def test_source_external_creates_proxy_for_source_only(tmp_path: Path) -> None: + _write_sources(tmp_path) + (tmp_path / ".trailmark" / "links.toml").write_text( + '[[link]]\nsource = "queue:job"\ntarget = "caller:invoke"\nsource_external = true\n', + ) + + graph = parse_directory(str(tmp_path), "python") + assert graph.nodes["proxy.external:queue:job"].origin == NodeOrigin.PROXY + assert "proxy.external:caller:invoke" not in graph.nodes @pytest.mark.parametrize( "body,match", [ ("link = {}\n", "'link' must be an array"), + ("metadata = {}\n", "unknown top-level key"), ('[[link]]\nsource = "invoke"\n', "'target' must be"), + ('[[link]]\nsource = "invoke"\ntarget = "execute"\ndescripton = "typo"\n', "unknown key"), ('[[link]]\nsource = "invoke"\ntarget = "execute"\nkind = "invalid"\n', "invalid kind"), ], ) diff --git a/tests/test_sql_parser.py b/tests/test_sql_parser.py index 785a3ea..e75959b 100644 --- a/tests/test_sql_parser.py +++ b/tests/test_sql_parser.py @@ -97,3 +97,65 @@ def test_sql_function_invocation_is_not_relation_dependency(tmp_path: Path) -> N assert "functions:app.events" in dependencies assert "functions:count" not in dependencies assert "functions:count" not in graph.nodes + + +def test_commented_out_procedure_is_not_recovered(tmp_path: Path) -> None: + source = tmp_path / "comments.sql" + source.write_text("-- CREATE PROCEDURE app.disabled() LANGUAGE SQL AS $$ SELECT 1 $$;\n") + graph = parse_file(str(source), "sql") + + assert "comments:app.disabled" not in graph.nodes + + +def test_commented_out_relation_is_not_dependency(tmp_path: Path) -> None: + source = tmp_path / "comments.sql" + source.write_text( + "CREATE TABLE app.events (id bigint);\n" + "CREATE PROCEDURE app.refresh() LANGUAGE SQL\n" + "-- FROM app.ignored\n" + "AS $$ SELECT id FROM app.events $$;\n", + ) + graph = parse_file(str(source), "sql") + + dependencies = {edge.target_id for edge in graph.edges if edge.kind == EdgeKind.CORRESPONDS_TO} + assert "comments:app.events" in dependencies + assert "comments:app.ignored" not in dependencies + + +def test_dollar_quoted_body_dependency_is_extracted_despite_comment_markers( + tmp_path: Path, +) -> None: + source = tmp_path / "body.sql" + source.write_text( + "CREATE TABLE app.events (id bigint);\n" + "CREATE PROCEDURE app.refresh() LANGUAGE SQL AS $$\n" + "SELECT id FROM app.events; -- body comment marker remains inside body\n" + "$$;\n", + ) + graph = parse_file(str(source), "sql") + + assert any( + edge.source_id == "body:app.refresh" + and edge.target_id == "body:app.events" + and edge.kind == EdgeKind.CORRESPONDS_TO + for edge in graph.edges + ) + + +def test_schema_and_unqualified_table_same_name_have_distinct_nodes(tmp_path: Path) -> None: + source = tmp_path / "collision.sql" + source.write_text( + "CREATE SCHEMA foo;\n" + "CREATE TABLE foo (id bigint);\n" + "CREATE VIEW visible AS SELECT id FROM foo;\n", + ) + graph = parse_file(str(source), "sql") + + assert graph.nodes["collision:foo"].kind == NodeKind.SCHEMA + assert graph.nodes["collision:foo#table"].kind == NodeKind.TABLE + assert any( + edge.source_id == "collision:visible" + and edge.target_id == "collision:foo#table" + and edge.kind == EdgeKind.CORRESPONDS_TO + for edge in graph.edges + )