Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 15 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,10 @@ classDiagram

Unresolved calls are materialized as proxy nodes such as
`proxy.unresolved:<raw-symbol>` 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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
26 changes: 20 additions & 6 deletions src/trailmark/analysis/entrypoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@

from __future__ import annotations

import dataclasses
import re
import tomllib
from pathlib import Path
Expand All @@ -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"

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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]] = {}
Expand Down Expand Up @@ -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,
Expand Down
53 changes: 45 additions & 8 deletions src/trailmark/analysis/links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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"
Expand All @@ -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")
Expand All @@ -70,17 +75,46 @@ 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():
_invalid(index, f"'{key}' must be a non-empty string")
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:
Expand All @@ -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)


Expand Down
2 changes: 1 addition & 1 deletion src/trailmark/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
86 changes: 81 additions & 5 deletions src/trailmark/parsers/sql/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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")
Expand All @@ -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,
Expand All @@ -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:
Expand Down
Loading