diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10dbb59..c696a04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,15 +18,15 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b # v4.0.0 + - uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89 # v4.1.0 with: args: check src/ tests/ - - uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b # v4.0.0 + - uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89 # v4.1.0 with: args: format --check src/ tests/ @@ -34,13 +34,13 @@ jobs: name: Type Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" @@ -55,16 +55,32 @@ jobs: matrix: python-version: ["3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python-version }} - run: uv sync --all-groups - run: uv run pytest -q tests/ + + package: + name: Package Smoke Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 + + - run: uv build + - run: uv venv /tmp/trailmark-wheel && uv pip install --python /tmp/trailmark-wheel dist/*.whl + - run: /tmp/trailmark-wheel/bin/python tests/package_smoke.py tests/fixtures/kat + - run: uv venv /tmp/trailmark-sdist && uv pip install --python /tmp/trailmark-sdist dist/*.tar.gz + - run: /tmp/trailmark-sdist/bin/python tests/package_smoke.py tests/fixtures/kat diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 7945253..b17805f 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -18,13 +18,13 @@ jobs: name: Mutation Testing runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index edc6866..b2dc645 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,20 +13,24 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1 with: enable-cache: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" - run: uv build + - run: uv venv /tmp/trailmark-release && uv pip install --python /tmp/trailmark-release dist/*.whl + + - run: /tmp/trailmark-release/bin/python tests/package_smoke.py tests/fixtures/kat + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist @@ -47,7 +51,7 @@ jobs: name: dist path: dist/ - - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: "dist/*" diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index e5349b5..d34018e 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -18,11 +18,11 @@ jobs: name: Semgrep Scan runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 674a9ea..02027ac 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -23,11 +23,11 @@ jobs: name: Workflow Security Audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + - uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 with: version: latest advanced-security: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f04448f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## 0.5.0 + +- 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). +- 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). +- 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. + +New `NodeKind` members are additive. `.trailmark/links.toml` is a new stable +configuration interface. Dynamic dispatch, full SQL query semantics, and true +interprocedural taint analysis remain out of scope for this release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec80fee..4319052 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Setup -Requires Python >= 3.13 and [uv](https://docs.astral.sh/uv/). +Requires Python >= 3.12 and [uv](https://docs.astral.sh/uv/). ```bash uv sync --all-groups diff --git a/README.md b/README.md index 49503de..d600aa8 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ A language-specific parser walks the directory, parses each file into a tree-sit | Proto | `.proto` | services, RPCs, messages, fields, enums | | Thrift | `.thrift` | services, functions, structs, fields, enums | | GraphQL | `.graphql`, `.gql` | object types, root operations, fields, enums | +| SQL | `.sql` | schemas, tables, views, functions, procedures | ```mermaid flowchart TD @@ -117,7 +118,7 @@ The `QueryEngine` provides a high-level API over the indexed graph: | `paths_between(src, dst)` | All simple call paths between two nodes | | `connect_subgraphs(source, target)` | Paths connecting two named subgraphs | | `entrypoint_paths_to(name)` | Paths from any detected entrypoint to the target | -| `attack_surface()` | Entrypoints tagged with trust level and asset value | +| `attack_surface()` | Entrypoints tagged with trust level, asset value, and parser attributes when present | | `complexity_hotspots(n)` | Functions with cyclomatic complexity ≥ n | | `functions_that_raise(exc)` | Functions whose parser-detected exception list includes `exc` | | `generic_parameters(name)` | Generic type parameters declared by a node | @@ -261,6 +262,14 @@ uv sync --all-groups Requires Python ≥ 3.12. +Trailmark uses `tree-sitter-language-pack` for most grammars. Current releases +use the platform certificate store for grammar downloads. In TLS-inspected or +offline environments, pre-populate the package cache with +`python -c "import tree_sitter_language_pack as p; p.download_all()"` on a +matching platform, then copy the resulting `tree-sitter-language-pack` cache +directory to the target machine. `HTTPS_PROXY` is also honored. The SQL grammar +ships as the `tree-sitter-sql` wheel dependency and does not use that cache. + ## Usage ```bash @@ -392,6 +401,52 @@ 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 +entrypoint attributes when they are attached to the underlying graph node. + +### Cross-language and external links + +Polyglot parsing merges language graphs, but many RPC, FFI, subprocess, and +host/contract relationships are not visible in source syntax. Declare these +deterministically in `.trailmark/links.toml`: + +```toml +[[link]] +source = "backend:submit" +target = "contract:Verifier.verify" +kind = "calls" # defaults to calls +confidence = "certain" # defaults to inferred +description = "JSON-RPC eth_call" + +[[link]] +source = "backend:notify" +target = "payments-webhook" +external = true # required when either endpoint 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. + +### Analysis limitations + +- `entrypoint_paths_to()` reports call-graph reachability, not attacker-controlled + data flow. Use preanalysis taint results as a coarse separate signal; Trailmark + does not yet perform interprocedural taint analysis. +- TypeScript resolves direct calls and straightforward receivers assigned with + `new ConcreteClass()`. Interface dispatch through manifests, computed property + names, dependency-injection containers, and other dynamic mechanisms remains + best-effort. +- SQL support is PostgreSQL-oriented and extracts schemas, tables, views, + functions, procedures, and routine/view dependencies. It is not a complete + SQL dialect validator or query-semantic analyzer. + ### Programmatic API ```python @@ -456,6 +511,9 @@ engine.augment_sarif("results.sarif") engine.augment_weaudit("findings.json") ``` +`NodeKind.SCHEMA`, `TABLE`, `VIEW`, and `PROCEDURE` are additive in v0.5.0; +consumers that exhaustively match enum values should add cases for them. + ## Development ```bash diff --git a/pyproject.toml b/pyproject.toml index cd34638..07df641 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "trailmark" -version = "0.4.0" +version = "0.5.0" description = "Parse source code into a queryable graph of functions, classes, calls, and semantic annotations" readme = "README.md" requires-python = ">=3.12" @@ -14,7 +14,8 @@ authors = [ ] dependencies = [ "tree-sitter~=0.25.0", - "tree-sitter-language-pack>=1.8,<2.0", + "tree-sitter-language-pack>=1.9,<2.0", + "tree-sitter-sql>=0.3.11,<0.4", "rustworkx>=0.17,<1.0", ] diff --git a/src/trailmark/__init__.py b/src/trailmark/__init__.py index 0f91214..0c9f9e8 100644 --- a/src/trailmark/__init__.py +++ b/src/trailmark/__init__.py @@ -13,4 +13,4 @@ "parse_file", "supported_languages", ] -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/src/trailmark/analysis/entrypoints.py b/src/trailmark/analysis/entrypoints.py index e389a62..08ffaa9 100644 --- a/src/trailmark/analysis/entrypoints.py +++ b/src/trailmark/analysis/entrypoints.py @@ -56,6 +56,7 @@ EntrypointTag, TrustLevel, ) +from trailmark.models.edges import EdgeKind from trailmark.models.graph import CodeGraph from trailmark.models.nodes import CodeUnit @@ -94,12 +95,6 @@ _RS_NO_MANGLE = re.compile(r"^\s*#\[\s*no_mangle\s*\]\s*$") _RS_EXTERN_C_FN = re.compile(r"\bpub\s+extern\s+\"C\"\s+fn\b") -# Solidity function visibility — scan the signature line itself. -_SOL_VISIBILITY = re.compile( - r"\bfunction\s+\w+\s*\([^)]*\)\s*(?:[\w\s]*?\b)?(external|public)\b", -) -_SOL_SPECIAL = re.compile(r"^\s*(fallback|receive)\s*\(\s*\)") - # JS/TS — NestJS method decorators: @Get(), @Post(), @Put(), @Delete(), @Patch(), # @Options(), @Head(), @All(). Capital first letter distinguishes from free # functions named `get`/`post`/etc. @@ -290,6 +285,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) return result @@ -431,17 +427,18 @@ def _detect_solidity( unit: CodeUnit, path: str, ) -> EntrypointTag | None: - signature = cache.signature_block(path, unit.location.start_line) - if signature is None: + del cache, path + attributes = dict(unit.attributes) + if attributes.get("solidity_container_kind") == "interface": return None - if _SOL_SPECIAL.search(signature): + if unit.name in {"fallback", "receive"}: return EntrypointTag( kind=EntrypointKind.API, trust_level=TrustLevel.UNTRUSTED_EXTERNAL, description="Solidity fallback/receive", asset_value=AssetValue.HIGH, ) - if _SOL_VISIBILITY.search(signature): + if attributes.get("solidity_visibility") in {"external", "public"}: return EntrypointTag( kind=EntrypointKind.API, trust_level=TrustLevel.UNTRUSTED_EXTERNAL, @@ -451,6 +448,49 @@ def _detect_solidity( return None +def _suppress_overridden_solidity_entrypoints( + graph: CodeGraph, + detected: dict[str, EntrypointTag], +) -> None: + """Remove 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"): + containers[edge.target_id] = edge.source_id + + methods: dict[tuple[str, tuple[str, ...]], dict[str, str]] = {} + for method_id, contract_id in containers.items(): + unit = graph.nodes[method_id] + signature = ( + unit.name, + tuple(p.type_ref.name if p.type_ref else "" for p in unit.parameters), + ) + methods.setdefault(signature, {})[contract_id] = method_id + + bases: dict[str, set[str]] = {} + for edge in graph.edges: + if edge.kind == EdgeKind.INHERITS: + bases.setdefault(edge.source_id, set()).add(edge.target_id) + + for by_contract in methods.values(): + for derived, method_id in by_contract.items(): + if method_id not in detected: + continue + stack = list(bases.get(derived, ())) + visited: set[str] = set() + while stack: + base = stack.pop() + if base in visited: + continue + visited.add(base) + base_method = by_contract.get(base) + if base_method is not None: + detected.pop(base_method, None) + stack.extend(bases.get(base, ())) + + def _detect_js_ts( cache: _SourceCache, unit: CodeUnit, diff --git a/src/trailmark/analysis/links.py b/src/trailmark/analysis/links.py new file mode 100644 index 0000000..a59389b --- /dev/null +++ b/src/trailmark/analysis/links.py @@ -0,0 +1,144 @@ +"""Load explicit cross-language and external links from repository configuration.""" + +from __future__ import annotations + +import re +import tomllib +from pathlib import Path +from typing import Any, NoReturn, cast + +from trailmark.models.edges import CodeEdge, EdgeConfidence, EdgeKind +from trailmark.models.graph import CodeGraph +from trailmark.models.nodes import CodeUnit, NodeKind, NodeOrigin, SourceLocation + +LINKS_FILE = Path(".trailmark/links.toml") + + +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``. + """ + config_path = Path(root_path).resolve() / LINKS_FILE + if not config_path.is_file(): + return + try: + data = tomllib.loads(config_path.read_text()) + except (OSError, tomllib.TOMLDecodeError) as exc: + msg = f"Invalid {LINKS_FILE}: {exc}" + raise ValueError(msg) from exc + + entries = data.get("link", []) + if not isinstance(entries, list): + msg = f"Invalid {LINKS_FILE}: 'link' must be an array of tables" + raise ValueError(msg) + for index, raw in enumerate(entries, start=1): + _apply_link(graph, raw, index, config_path) + + +def _apply_link(graph: CodeGraph, raw: object, index: int, config_path: Path) -> None: + if not isinstance(raw, dict): + _invalid(index, "entry must be a table") + entry = cast("dict[str, Any]", raw) + 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_id = _resolve_endpoint(graph, source_ref, external, index, config_path) + target_id = _resolve_endpoint(graph, target_ref, external, index, config_path) + kind = _edge_kind(entry.get("kind", "calls"), index) + confidence = _edge_confidence(entry.get("confidence", "inferred"), index) + description = entry.get("description") + if description is not None and not isinstance(description, str): + _invalid(index, "'description' must be a string") + + attributes = (("configured_by", str(LINKS_FILE)),) + if description: + attributes += (("description", description),) + edge = CodeEdge( + source_id=source_id, + target_id=target_id, + kind=kind, + confidence=confidence, + attributes=attributes, + ) + if edge not in graph.edges: + graph.edges.append(edge) + + +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 _resolve_endpoint( + graph: CodeGraph, + reference: str, + external: bool, + index: int, + config_path: Path, +) -> str: + if reference in graph.nodes: + return reference + matches = [ + node_id + for node_id, unit in graph.nodes.items() + if unit.name == reference + or node_id.endswith(f":{reference}") + or node_id.endswith(f".{reference}") + ] + if len(matches) == 1: + return matches[0] + 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)") + return _add_external_proxy(graph, reference, config_path) + + +def _add_external_proxy(graph: CodeGraph, reference: str, config_path: Path) -> str: + escaped = re.sub(r"[^0-9A-Za-z_.@$:-]+", "_", reference.strip()) or "unknown" + node_id = f"proxy.external:{escaped}" + graph.nodes.setdefault( + node_id, + CodeUnit( + id=node_id, + name=reference, + kind=NodeKind.PROXY, + location=SourceLocation(str(config_path), 0, 0), + origin=NodeOrigin.PROXY, + attributes=(("raw_symbol", reference), ("proxy_kind", "external")), + ), + ) + return node_id + + +def _edge_kind(value: object, index: int) -> EdgeKind: + if not isinstance(value, str): + _invalid(index, "'kind' must be a string") + try: + return EdgeKind(value) + except ValueError: + choices = ", ".join(item.value for item in EdgeKind) + _invalid(index, f"invalid kind {value!r}; expected one of: {choices}") + + +def _edge_confidence(value: object, index: int) -> EdgeConfidence: + if not isinstance(value, str): + _invalid(index, "'confidence' must be a string") + try: + return EdgeConfidence(value) + except ValueError: + choices = ", ".join(item.value for item in EdgeConfidence) + _invalid(index, f"invalid confidence {value!r}; expected one of: {choices}") + + +def _invalid(index: int, detail: str) -> NoReturn: + msg = f"Invalid {LINKS_FILE} [[link]] #{index}: {detail}" + raise ValueError(msg) diff --git a/src/trailmark/models/nodes.py b/src/trailmark/models/nodes.py index 51c1a7c..979395f 100644 --- a/src/trailmark/models/nodes.py +++ b/src/trailmark/models/nodes.py @@ -24,6 +24,10 @@ class NodeKind(Enum): CONTRACT = "contract" LIBRARY = "library" TEMPLATE = "template" + SCHEMA = "schema" + TABLE = "table" + VIEW = "view" + PROCEDURE = "procedure" PROXY = "proxy" diff --git a/src/trailmark/parse.py b/src/trailmark/parse.py index 1f4173b..e200e29 100644 --- a/src/trailmark/parse.py +++ b/src/trailmark/parse.py @@ -6,6 +6,7 @@ import os from pathlib import Path +from trailmark.analysis.links import apply_repository_links from trailmark.analysis.proxies import ensure_proxy_nodes from trailmark.models.graph import CodeGraph from trailmark.parsers._common import should_skip_dir @@ -41,6 +42,7 @@ "proto": ("trailmark.parsers.proto", "ProtoParser"), "thrift": ("trailmark.parsers.thrift", "ThriftParser"), "graphql": ("trailmark.parsers.graphql", "GraphQLParser"), + "sql": ("trailmark.parsers.sql", "SQLParser"), } # Extensions used for language auto-detection. Keep these aligned with each @@ -79,6 +81,7 @@ "proto": (".proto",), "thrift": (".thrift",), "graphql": (".graphql", ".gql"), + "sql": (".sql",), } _SUPPORTED_LANGUAGES = tuple(_PARSER_MAP.keys()) @@ -197,7 +200,9 @@ 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. - return _get_parser(languages[0]).parse_directory(path) + graph = _get_parser(languages[0]).parse_directory(path) + apply_repository_links(graph, path) + return ensure_proxy_nodes(graph) merged = CodeGraph(language="polyglot", root_path=str(Path(path).resolve())) for lang in languages: @@ -205,6 +210,7 @@ def _parse_and_merge(path: str, languages: list[str]) -> CodeGraph: merged.merge(sub) # merge() doesn't touch `language`; preserve the polyglot marker. merged.language = "polyglot" + apply_repository_links(merged, path) return ensure_proxy_nodes(merged) diff --git a/src/trailmark/parsers/csharp/parser.py b/src/trailmark/parsers/csharp/parser.py index a9c9dba..fa8ed19 100644 --- a/src/trailmark/parsers/csharp/parser.py +++ b/src/trailmark/parsers/csharp/parser.py @@ -91,10 +91,23 @@ def _visit_module( module_id: str, graph: CodeGraph, ) -> None: - """Walk the top-level of a module, extracting nodes and edges.""" + """Walk the top-level of a module, extracting nodes and edges. + + A file-scoped namespace declaration (``namespace X;``, C# 10+) has no + ``body`` field: every top-level declaration after it, to the end of + the compilation unit, belongs to that namespace. Track it while + walking and route subsequent children through ``_visit_ns_child``, + mirroring what ``_extract_namespace`` does with a braced body. + """ add_module_node(root, file_path, module_id, graph) + ns_id = None for child in root.children: - _visit_top_level(child, file_path, module_id, graph) + if child.type == "file_scoped_namespace_declaration": + ns_id = _create_namespace_unit(child, file_path, module_id, graph) + elif ns_id is not None: + _visit_ns_child(child, file_path, module_id, ns_id, graph) + else: + _visit_top_level(child, file_path, module_id, graph) def _visit_top_level( @@ -120,16 +133,16 @@ def _visit_top_level( _extract_import(child, graph) -def _extract_namespace( +def _create_namespace_unit( node: Node, file_path: str, module_id: str, graph: CodeGraph, -) -> None: - """Extract a namespace declaration and its children.""" +) -> str | None: + """Create a NAMESPACE CodeUnit and its CONTAINS edge; return its id.""" name_node = node.child_by_field_name("name") if name_node is None: - return + return None ns_name = node_text(name_node) ns_id = f"{module_id}:{ns_name}" location = make_location(node, file_path) @@ -142,7 +155,19 @@ def _extract_namespace( ) graph.nodes[ns_id] = ns_unit add_contains_edge(graph, module_id, ns_id) + return ns_id + +def _extract_namespace( + node: Node, + file_path: str, + module_id: str, + graph: CodeGraph, +) -> None: + """Extract a block-scoped namespace declaration and its children.""" + ns_id = _create_namespace_unit(node, file_path, module_id, graph) + if ns_id is None: + return body = node.child_by_field_name("body") if body is None: return diff --git a/src/trailmark/parsers/solidity/parser.py b/src/trailmark/parsers/solidity/parser.py index 71f8db4..dc652b2 100644 --- a/src/trailmark/parsers/solidity/parser.py +++ b/src/trailmark/parsers/solidity/parser.py @@ -50,6 +50,7 @@ "function_definition", "constructor_definition", "modifier_definition", + "fallback_receive_definition", } ) @@ -188,7 +189,14 @@ def _visit_contract_body( return for child in body.children: if child.type in _FUNCTION_TYPES: - _extract_function(child, file_path, module_id, contract_id, graph) + _extract_function( + child, + file_path, + module_id, + contract_id, + graph, + container_kind=graph.nodes[contract_id].kind, + ) elif child.type == "struct_declaration": _extract_struct(child, file_path, module_id, graph) elif child.type == "enum_declaration": @@ -245,6 +253,8 @@ def _extract_function( module_id: str, contract_id: str | None, graph: CodeGraph, + *, + container_kind: NodeKind | None = None, ) -> None: """Extract a function, constructor, or modifier definition.""" func_name = _get_function_name(node) @@ -279,6 +289,7 @@ def _extract_function( cyclomatic_complexity=complexity, branches=tuple(branches), docstring=docstring, + attributes=_function_attributes(node, container_kind), ) graph.nodes[func_id] = unit add_contains_edge(graph, owner, func_id) @@ -290,12 +301,34 @@ def _get_function_name(node: Node) -> str: """Extract the function name from a definition node.""" if node.type == "constructor_definition": return "constructor" + if node.type == "fallback_receive_definition": + for child in node.children: + if child.type in {"fallback", "receive"}: + return child.type name_node = node.child_by_field_name("name") if name_node is None: return "" return node_text(name_node) +def _function_attributes( + node: Node, + container_kind: NodeKind | None, +) -> tuple[tuple[str, str], ...]: + """Record Solidity semantics needed by entrypoint consumers.""" + attributes: list[tuple[str, str]] = [] + for child in node.children: + if child.type == "visibility": + attributes.append(("solidity_visibility", node_text(child))) + elif child.type == "state_mutability": + attributes.append(("solidity_mutability", node_text(child))) + elif child.type == "override_specifier": + attributes.append(("solidity_override", "true")) + if container_kind is not None: + attributes.append(("solidity_container_kind", container_kind.value)) + return tuple(attributes) + + def _collect_func_body( body: Node | None, file_path: str, diff --git a/src/trailmark/parsers/sql/__init__.py b/src/trailmark/parsers/sql/__init__.py new file mode 100644 index 0000000..07eceae --- /dev/null +++ b/src/trailmark/parsers/sql/__init__.py @@ -0,0 +1,5 @@ +"""SQL parser package.""" + +from trailmark.parsers.sql.parser import SQLParser + +__all__ = ["SQLParser"] diff --git a/src/trailmark/parsers/sql/parser.py b/src/trailmark/parsers/sql/parser.py new file mode 100644 index 0000000..aaa8e9a --- /dev/null +++ b/src/trailmark/parsers/sql/parser.py @@ -0,0 +1,319 @@ +"""PostgreSQL-oriented SQL parser using tree-sitter-sql.""" + +from __future__ import annotations + +import dataclasses +import re +from pathlib import Path + +import tree_sitter_sql +from tree_sitter import Language, Node, Parser + +from trailmark.models.edges import CodeEdge, EdgeConfidence, EdgeKind +from trailmark.models.graph import CodeGraph +from trailmark.models.nodes import ( + CodeUnit, + NodeKind, + NodeOrigin, + Parameter, + SourceLocation, + TypeRef, +) +from trailmark.parsers._common import ( + add_contains_edge, + add_module_node, + make_location, + module_id_from_path, + node_text, + parse_directory, +) + +_EXTENSIONS = (".sql",) +_DECLARATIONS: dict[str, NodeKind] = { + "create_schema": NodeKind.SCHEMA, + "create_table": NodeKind.TABLE, + "create_view": NodeKind.VIEW, + "create_materialized_view": NodeKind.VIEW, + "create_function": NodeKind.FUNCTION, + "create_procedure": NodeKind.PROCEDURE, +} +_PROCEDURE = re.compile( + r"\bCREATE\s+(?:OR\s+REPLACE\s+)?PROCEDURE\s+" + r"(?:(?P[A-Za-z_][\w$]*)\.)?(?P[A-Za-z_][\w$]*)\s*\(", + re.IGNORECASE, +) +_RELATION_REFERENCE = re.compile( + r"\b(?:FROM|JOIN|UPDATE|INTO)\s+" + r"(?:(?P[A-Za-z_][\w$]*)\.)?(?P[A-Za-z_][\w$]*)", + re.IGNORECASE, +) +_NEXT_CREATE = re.compile( + r"\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:SCHEMA|TABLE|VIEW|FUNCTION|PROCEDURE)\b", re.IGNORECASE +) + + +class SQLParser: + """Parse common PostgreSQL-style schema and routine declarations.""" + + @property + def language(self) -> str: + return "sql" + + def __init__(self) -> None: + self._parser = Parser(Language(tree_sitter_sql.language())) + + def parse_file(self, file_path: str) -> CodeGraph: + return self._parse_file(file_path, materialize_dependencies=True) + + def _parse_file(self, file_path: str, *, materialize_dependencies: bool) -> CodeGraph: + source = Path(file_path).read_bytes() + tree = self._parser.parse(source) + graph = CodeGraph(language="sql", root_path=file_path) + module_id = module_id_from_path(file_path) + add_module_node(tree.root_node, file_path, module_id, graph) + for node in _walk(tree.root_node): + kind = _DECLARATIONS.get(node.type) + if kind is not None: + _extract_declaration(node, kind, file_path, module_id, graph) + _extract_procedures(source, file_path, module_id, graph) + if materialize_dependencies: + _materialize_sql_dependency_targets(graph, file_path) + return graph + + def parse_directory(self, dir_path: str) -> CodeGraph: + graph = parse_directory( + lambda file_path: self._parse_file(file_path, materialize_dependencies=False), + "sql", + dir_path, + _EXTENSIONS, + ) + _link_cross_file_sql_dependencies(graph) + _materialize_sql_dependency_targets(graph, dir_path) + return graph + + +def _extract_declaration( + node: Node, + kind: NodeKind, + file_path: str, + module_id: str, + graph: CodeGraph, +) -> None: + qualified = _declaration_name(node) + if qualified is None: + return + schema, name = qualified + unit_id = _sql_id(module_id, schema, name) + container_id = module_id + if kind == NodeKind.SCHEMA: + name = schema or name + unit_id = f"{module_id}:{name}" + elif schema: + schema_id = f"{module_id}:{schema}" + if schema_id in graph.nodes: + container_id = schema_id + + parameters = ( + _routine_parameters(node) if kind in {NodeKind.FUNCTION, NodeKind.PROCEDURE} else () + ) + graph.nodes[unit_id] = CodeUnit( + id=unit_id, + name=name, + kind=kind, + location=make_location(node, file_path), + parameters=parameters, + attributes=(("sql_schema", schema),) if schema else (), + ) + add_contains_edge(graph, container_id, unit_id) + + if kind in {NodeKind.VIEW, NodeKind.FUNCTION, NodeKind.PROCEDURE}: + _add_dependencies(node, unit_id, qualified, module_id, file_path, graph) + + +def _declaration_name(node: Node) -> tuple[str | None, str] | None: + for child in node.named_children: + if child.type == "object_reference": + return _object_reference(child) + if node.type == "create_schema": + for child in node.named_children: + if child.type == "identifier": + return (None, node_text(child)) + return None + + +def _object_reference(node: Node) -> tuple[str | None, str] | None: + schema_node = node.child_by_field_name("schema") + name_node = node.child_by_field_name("name") + if name_node is not None: + return (node_text(schema_node) if schema_node is not None else None, node_text(name_node)) + identifiers = [node_text(child) for child in node.named_children if child.type == "identifier"] + if len(identifiers) == 1: + return (None, identifiers[0]) + if len(identifiers) >= 2: + return (identifiers[-2], identifiers[-1]) + return None + + +def _routine_parameters(node: Node) -> tuple[Parameter, ...]: + result: list[Parameter] = [] + for child in _walk(node): + if child.type != "function_argument": + continue + named = child.named_children + if not named: + continue + name = node_text(named[0]) + type_ref = TypeRef(node_text(named[1])) if len(named) > 1 else None + result.append(Parameter(name=name, type_ref=type_ref)) + return tuple(result) + + +def _add_dependencies( + node: Node, + source_id: str, + declared: tuple[str | None, str], + module_id: str, + file_path: str, + graph: CodeGraph, +) -> None: + skipped_declaration = False + seen: set[str] = set() + for child in _walk(node): + if child.type != "object_reference": + continue + reference = _object_reference(child) + if reference is None: + continue + if not skipped_declaration and reference == declared: + skipped_declaration = True + continue + if not _is_relation_reference(child): + continue + target_id = _sql_id(module_id, *reference) + if target_id == source_id or target_id in seen: + continue + seen.add(target_id) + graph.edges.append( + CodeEdge( + source_id=source_id, + target_id=target_id, + kind=EdgeKind.CORRESPONDS_TO, + confidence=EdgeConfidence.CERTAIN, + location=make_location(child, file_path), + attributes=(("relationship", "sql_dependency"),), + ) + ) + + +def _is_relation_reference(node: Node) -> bool: + """Return true for references used as query relations rather than calls.""" + parent = node.parent + while parent is not None: + if parent.type == "invocation": + return False + if parent.type == "relation": + return True + parent = parent.parent + return False + + +def _sql_id(module_id: str, schema: str | None, name: str) -> str: + qualified = f"{schema}.{name}" if schema else name + return f"{module_id}:{qualified}" + + +def _extract_procedures( + source: bytes, + file_path: str, + module_id: str, + graph: CodeGraph, +) -> None: + """Recover PostgreSQL procedures unsupported by the permissive grammar.""" + text = source.decode("utf-8", errors="replace") + matches = list(_PROCEDURE.finditer(text)) + for match_index, match in enumerate(matches): + schema = match.group("schema") + name = match.group("name") + unit_id = _sql_id(module_id, schema, name) + if unit_id in graph.nodes: + continue + start_line = text.count("\n", 0, match.start()) + 1 + container_id = f"{module_id}:{schema}" if schema else module_id + if container_id not in graph.nodes: + container_id = module_id + graph.nodes[unit_id] = CodeUnit( + id=unit_id, + name=name, + kind=NodeKind.PROCEDURE, + location=SourceLocation(file_path, start_line, start_line), + attributes=(("sql_schema", schema),) if schema else (), + ) + add_contains_edge(graph, container_id, unit_id) + next_procedure = ( + matches[match_index + 1].start() if match_index + 1 < len(matches) else len(text) + ) + next_declaration = _NEXT_CREATE.search(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")) + graph.edges.append( + CodeEdge( + source_id=unit_id, + target_id=target_id, + kind=EdgeKind.CORRESPONDS_TO, + confidence=EdgeConfidence.INFERRED, + attributes=(("relationship", "sql_dependency"),), + ) + ) + + +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: + continue + graph.nodes[edge.target_id] = CodeUnit( + id=edge.target_id, + name=edge.target_id.rsplit(":", 1)[-1], + kind=NodeKind.PROXY, + location=edge.location or SourceLocation(file_path, 0, 0), + origin=NodeOrigin.PROXY, + attributes=( + ("raw_symbol", edge.target_id), + ("proxy_kind", "sql_relation"), + ), + ) + + +def _link_cross_file_sql_dependencies(graph: CodeGraph) -> None: + by_qualified_name: dict[str, list[str]] = {} + for node_id, unit in graph.nodes.items(): + if unit.kind not in {NodeKind.TABLE, NodeKind.VIEW, NodeKind.FUNCTION, NodeKind.PROCEDURE}: + continue + schema = dict(unit.attributes).get("sql_schema") + qualified = f"{schema}.{unit.name}" if schema else unit.name + by_qualified_name.setdefault(qualified, []).append(node_id) + + rewritten: list[CodeEdge] = [] + for edge in graph.edges: + if edge.kind != EdgeKind.CORRESPONDS_TO or edge.target_id in graph.nodes: + rewritten.append(edge) + continue + qualified = edge.target_id.rsplit(":", 1)[-1] + matches = by_qualified_name.get(qualified, []) + rewritten.append( + dataclasses.replace(edge, target_id=matches[0]) if len(matches) == 1 else edge + ) + graph.edges = rewritten + + +def _walk(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 diff --git a/src/trailmark/parsers/typescript/parser.py b/src/trailmark/parsers/typescript/parser.py index e477b60..d4f3ea6 100644 --- a/src/trailmark/parsers/typescript/parser.py +++ b/src/trailmark/parsers/typescript/parser.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from pathlib import Path from tree_sitter import Node, Parser @@ -835,11 +836,9 @@ def _add_call_edges( ) -> None: """Add CALLS edges from collected call information.""" for call_name, call_node in calls: - target_id = _resolve_call_target( - call_name, - module_id, - class_id, - ) + target_id = _resolve_concrete_receiver(call_name, call_node, module_id) + if target_id is None: + target_id = _resolve_call_target(call_name, module_id, class_id) confidence = _call_confidence(call_name) graph.edges.append( CodeEdge( @@ -852,6 +851,41 @@ def _add_call_edges( ) +def _resolve_concrete_receiver(call_name: str, call_node: Node, module_id: str) -> str | None: + """Resolve ``receiver.method()`` when receiver is assigned ``new Class``. + + This deliberately handles only direct construction/assignment in the + enclosing function. Manifest tables and arbitrary dynamic dispatch remain + unresolved rather than being guessed. + """ + if call_name.count(".") != 1: + return None + 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 + 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: + return None + concrete_class = matches[-1].group("class") + return f"{module_id}:{concrete_class}.{method}" + + 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 8620e40..2d90b05 100644 --- a/src/trailmark/query/api.py +++ b/src/trailmark/query/api.py @@ -196,16 +196,20 @@ def functions_that_raise( def attack_surface(self) -> list[dict[str, Any]]: """List all entrypoints with their trust levels.""" - return [ - { + result: list[dict[str, Any]] = [] + for node_id, tag in self._store.all_entrypoints(): + item: dict[str, Any] = { "node_id": node_id, "trust_level": tag.trust_level.value, "kind": tag.kind.value, "asset_value": tag.asset_value.value, "description": tag.description, } - for node_id, tag in self._store.all_entrypoints() - ] + unit = self._store._graph.nodes.get(node_id) # noqa: SLF001 + if unit is not None and unit.attributes: + item["attributes"] = dict(unit.attributes) + result.append(item) + return result def complexity_hotspots( self, diff --git a/tests/fixtures/kat/solidity/taxonomy.expected.json b/tests/fixtures/kat/solidity/taxonomy.expected.json index 3350caa..291536c 100644 --- a/tests/fixtures/kat/solidity/taxonomy.expected.json +++ b/tests/fixtures/kat/solidity/taxonomy.expected.json @@ -206,6 +206,9 @@ "return_type": null }, "taxonomy:Animal.constructor": { + "attributes": { + "solidity_container_kind": "contract" + }, "branches": [], "cyclomatic_complexity": 1, "docstring": null, @@ -234,6 +237,11 @@ "return_type": null }, "taxonomy:Animal.describe": { + "attributes": { + "solidity_container_kind": "contract", + "solidity_mutability": "view", + "solidity_visibility": "public" + }, "branches": [], "cyclomatic_complexity": 1, "docstring": null, @@ -274,6 +282,11 @@ "return_type": null }, "taxonomy:Dog.add": { + "attributes": { + "solidity_container_kind": "contract", + "solidity_mutability": "pure", + "solidity_visibility": "public" + }, "branches": [], "cyclomatic_complexity": 1, "docstring": null, @@ -315,6 +328,10 @@ } }, "taxonomy:Dog.bark": { + "attributes": { + "solidity_container_kind": "contract", + "solidity_visibility": "public" + }, "branches": [ { "complexity_contribution": 1, @@ -359,6 +376,11 @@ } }, "taxonomy:Dog.branchy": { + "attributes": { + "solidity_container_kind": "contract", + "solidity_mutability": "pure", + "solidity_visibility": "external" + }, "branches": [ { "complexity_contribution": 1, @@ -456,6 +478,9 @@ } }, "taxonomy:Dog.constructor": { + "attributes": { + "solidity_container_kind": "contract" + }, "branches": [], "cyclomatic_complexity": 1, "docstring": null, @@ -493,6 +518,12 @@ "return_type": null }, "taxonomy:Dog.greet": { + "attributes": { + "solidity_container_kind": "contract", + "solidity_mutability": "view", + "solidity_override": "true", + "solidity_visibility": "external" + }, "branches": [], "cyclomatic_complexity": 1, "docstring": null, @@ -525,6 +556,10 @@ } }, "taxonomy:Dog.useAnimal": { + "attributes": { + "solidity_container_kind": "contract", + "solidity_visibility": "external" + }, "branches": [], "cyclomatic_complexity": 1, "docstring": null, @@ -565,6 +600,11 @@ "return_type": null }, "taxonomy:IGreeter.greet": { + "attributes": { + "solidity_container_kind": "interface", + "solidity_mutability": "view", + "solidity_visibility": "external" + }, "branches": [], "cyclomatic_complexity": 1, "docstring": null, diff --git a/tests/fixtures/kat/sql/taxonomy.expected.json b/tests/fixtures/kat/sql/taxonomy.expected.json new file mode 100644 index 0000000..e4f42c3 --- /dev/null +++ b/tests/fixtures/kat/sql/taxonomy.expected.json @@ -0,0 +1,159 @@ +{ + "edges": [ + { + "confidence": "certain", + "kind": "contains", + "source": "taxonomy", + "target": "taxonomy:audit" + }, + { + "confidence": "certain", + "kind": "contains", + "source": "taxonomy:audit", + "target": "taxonomy:audit.events" + }, + { + "confidence": "certain", + "kind": "contains", + "source": "taxonomy:audit", + "target": "taxonomy:audit.recent_events" + }, + { + "attributes": { + "relationship": "sql_dependency" + }, + "confidence": "certain", + "kind": "corresponds_to", + "source": "taxonomy:audit.recent_events", + "target": "taxonomy:audit.events" + }, + { + "confidence": "certain", + "kind": "contains", + "source": "taxonomy:audit", + "target": "taxonomy:audit.event_count" + }, + { + "attributes": { + "relationship": "sql_dependency" + }, + "confidence": "certain", + "kind": "corresponds_to", + "source": "taxonomy:audit.event_count", + "target": "taxonomy:audit.events" + } + ], + "language": "sql", + "nodes": { + "taxonomy": { + "branches": [], + "cyclomatic_complexity": null, + "docstring": null, + "exception_types": [], + "id": "taxonomy", + "kind": "module", + "location": { + "end_col": 0, + "end_line": 6, + "file_path": "taxonomy.sql", + "start_col": 0, + "start_line": 1 + }, + "name": "taxonomy", + "parameters": [], + "return_type": null + }, + "taxonomy:audit": { + "branches": [], + "cyclomatic_complexity": null, + "docstring": null, + "exception_types": [], + "id": "taxonomy:audit", + "kind": "schema", + "location": { + "end_col": 19, + "end_line": 1, + "file_path": "taxonomy.sql", + "start_col": 0, + "start_line": 1 + }, + "name": "audit", + "parameters": [], + "return_type": null + }, + "taxonomy:audit.event_count": { + "attributes": { + "sql_schema": "audit" + }, + "branches": [], + "cyclomatic_complexity": null, + "docstring": null, + "exception_types": [], + "id": "taxonomy:audit.event_count", + "kind": "function", + "location": { + "end_col": 42, + "end_line": 5, + "file_path": "taxonomy.sql", + "start_col": 0, + "start_line": 4 + }, + "name": "event_count", + "parameters": [], + "return_type": null + }, + "taxonomy:audit.events": { + "attributes": { + "sql_schema": "audit" + }, + "branches": [], + "cyclomatic_complexity": null, + "docstring": null, + "exception_types": [], + "id": "taxonomy:audit.events", + "kind": "table", + "location": { + "end_col": 51, + "end_line": 2, + "file_path": "taxonomy.sql", + "start_col": 0, + "start_line": 2 + }, + "name": "events", + "parameters": [], + "return_type": null + }, + "taxonomy:audit.recent_events": { + "attributes": { + "sql_schema": "audit" + }, + "branches": [], + "cyclomatic_complexity": null, + "docstring": null, + "exception_types": [], + "id": "taxonomy:audit.recent_events", + "kind": "view", + "location": { + "end_col": 62, + "end_line": 3, + "file_path": "taxonomy.sql", + "start_col": 0, + "start_line": 3 + }, + "name": "recent_events", + "parameters": [], + "return_type": null + } + }, + "root_path": "taxonomy.sql", + "subgraphs": {}, + "summary": { + "call_edges": 0, + "classes": 0, + "dependencies": [], + "entrypoints": 0, + "functions": 1, + "proxies": 0, + "total_nodes": 5 + } +} diff --git a/tests/fixtures/kat/sql/taxonomy.sql b/tests/fixtures/kat/sql/taxonomy.sql new file mode 100644 index 0000000..a90c20b --- /dev/null +++ b/tests/fixtures/kat/sql/taxonomy.sql @@ -0,0 +1,5 @@ +CREATE SCHEMA audit; +CREATE TABLE audit.events (id bigint, payload text); +CREATE VIEW audit.recent_events AS SELECT id FROM audit.events; +CREATE FUNCTION audit.event_count() RETURNS bigint LANGUAGE SQL +AS $$ SELECT count(*) FROM audit.events $$; diff --git a/tests/package_smoke.py b/tests/package_smoke.py new file mode 100644 index 0000000..cfe8c5e --- /dev/null +++ b/tests/package_smoke.py @@ -0,0 +1,39 @@ +"""Smoke-test an installed distribution against every registered parser.""" + +from __future__ import annotations + +import sys +from importlib.metadata import files +from pathlib import Path + +import tree_sitter_sql + +from trailmark.parse import parse_directory, supported_languages + + +def main() -> None: + fixture_root = Path(sys.argv[1]).resolve() + missing: list[str] = [] + for language in supported_languages(): + fixture = fixture_root / language + if not fixture.is_dir(): + missing.append(language) + continue + graph = parse_directory(str(fixture), language) + if not graph.nodes: + msg = f"installed parser produced no nodes for {language}" + raise RuntimeError(msg) + if missing: + msg = f"missing installed-package fixtures: {', '.join(missing)}" + raise RuntimeError(msg) + if not callable(tree_sitter_sql.language): + msg = "tree-sitter-sql grammar binding is unavailable" + raise RuntimeError(msg) + sql_files = files("tree-sitter-sql") or () + if not any("license" in str(path).lower() for path in sql_files): + msg = "tree-sitter-sql distribution does not include its license metadata" + raise RuntimeError(msg) + + +if __name__ == "__main__": + main() diff --git a/tests/test_cli_parser.py b/tests/test_cli_parser.py index fca1fe6..5f5dcbf 100644 --- a/tests/test_cli_parser.py +++ b/tests/test_cli_parser.py @@ -10,6 +10,7 @@ from __future__ import annotations import argparse +from typing import cast import pytest @@ -25,7 +26,7 @@ def parser() -> argparse.ArgumentParser: def subparsers_map(parser: argparse.ArgumentParser) -> dict[str, argparse.ArgumentParser]: for action in parser._actions: # noqa: SLF001 if isinstance(action, argparse._SubParsersAction): # noqa: SLF001 - return dict(action.choices) + return cast("dict[str, argparse.ArgumentParser]", dict(action.choices)) raise AssertionError("No subparsers action on the CLI parser") diff --git a/tests/test_csharp_parser.py b/tests/test_csharp_parser.py index 796d1d5..914024f 100644 --- a/tests/test_csharp_parser.py +++ b/tests/test_csharp_parser.py @@ -257,6 +257,95 @@ def test_generic_return_type(self) -> None: assert len(get_items.return_type.generic_args) == 1 +FILE_SCOPED_NAMESPACE_CODE = """\ +using System; + +namespace Animals; + +/// Defines animal behavior. +public interface IAnimal +{ + string Speak(); +} + +/// A dog that implements IAnimal. +public class Dog : IAnimal +{ + public string Speak() + { + return "woof"; + } +} +""" + + +def _parse_file_scoped_namespace() -> tuple[str, CodeGraph]: + parser = CSharpParser() + with tempfile.NamedTemporaryFile( + suffix=".cs", + mode="w", + delete=False, + ) as f: + f.write(FILE_SCOPED_NAMESPACE_CODE) + f.flush() + graph = parser.parse_file(f.name) + os.unlink(f.name) + return f.name, graph + + +def _contains_count(graph: CodeGraph, source_id: str, target_id: str) -> int: + return sum( + 1 + for e in graph.edges + if e.kind == EdgeKind.CONTAINS and e.source_id == source_id and e.target_id == target_id + ) + + +class TestCSharpFileScopedNamespace: + def test_finds_namespace(self) -> None: + _, graph = _parse_file_scoped_namespace() + namespaces = [n for n in graph.nodes.values() if n.kind == NodeKind.NAMESPACE] + names = {n.name for n in namespaces} + assert "Animals" in names + + def test_module_contains_namespace(self) -> None: + path, graph = _parse_file_scoped_namespace() + module = next(n for n in graph.nodes.values() if n.kind == NodeKind.MODULE) + namespace = next(n for n in graph.nodes.values() if n.kind == NodeKind.NAMESPACE) + assert namespace.id == f"{module.id}:Animals" + assert namespace.location.file_path == path + assert _contains_count(graph, module.id, namespace.id) == 1 + + def test_class_attributed_to_namespace(self) -> None: + path, graph = _parse_file_scoped_namespace() + module = next(n for n in graph.nodes.values() if n.kind == NodeKind.MODULE) + namespace = next(n for n in graph.nodes.values() if n.kind == NodeKind.NAMESPACE) + dog = next(n for n in graph.nodes.values() if n.name == "Dog") + assert dog.id == f"{module.id}:Dog" + assert dog.location.file_path == path + assert _contains_count(graph, namespace.id, dog.id) == 1 + assert _contains_count(graph, module.id, dog.id) == 0 + + def test_interface_attributed_to_namespace(self) -> None: + _, graph = _parse_file_scoped_namespace() + module = next(n for n in graph.nodes.values() if n.kind == NodeKind.MODULE) + namespace = next(n for n in graph.nodes.values() if n.kind == NodeKind.NAMESPACE) + animal = next(n for n in graph.nodes.values() if n.name == "IAnimal") + assert animal.id == f"{module.id}:IAnimal" + assert _contains_count(graph, namespace.id, animal.id) == 1 + assert _contains_count(graph, module.id, animal.id) == 0 + + def test_using_before_namespace_still_imported(self) -> None: + _, graph = _parse_file_scoped_namespace() + assert "System" in graph.dependencies + + def test_method_still_found(self) -> None: + _, graph = _parse_file_scoped_namespace() + methods = [n for n in graph.nodes.values() if n.kind == NodeKind.METHOD] + names = {m.name for m in methods} + assert "Speak" in names + + class TestCSharpParseDirectory: def test_parses_multiple_files(self) -> None: parser = CSharpParser() diff --git a/tests/test_detector_tags.py b/tests/test_detector_tags.py index 2e49118..eeb72a8 100644 --- a/tests/test_detector_tags.py +++ b/tests/test_detector_tags.py @@ -189,6 +189,39 @@ def test_function_visibility_tags(self, tmp_path: Path, visibility: str) -> None assert ep["asset_value"] == "high" assert ep["description"] == "Solidity external/public function" + def test_interface_methods_are_not_entrypoints(self, tmp_path: Path) -> None: + (tmp_path / "I.sol").write_text( + "interface I { function inspect() external view returns (uint); }\n", + ) + engine = QueryEngine.from_directory(str(tmp_path), language="solidity") + assert engine.attack_surface() == [] + + def test_mutability_is_preserved_on_entrypoint_node(self, tmp_path: Path) -> None: + (tmp_path / "C.sol").write_text( + "contract C { function inspect() external view returns (uint) { return 1; } }\n", + ) + engine = QueryEngine.from_directory(str(tmp_path), language="solidity") + entrypoint = _by_name(tmp_path, "solidity", ".inspect") + assert entrypoint["kind"] == "api" + assert entrypoint["attributes"] == { + "solidity_container_kind": "contract", + "solidity_mutability": "view", + "solidity_visibility": "external", + } + unit = engine._store._graph.nodes["C:C.inspect"] # noqa: SLF001 + 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: + (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", + ) + 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 + class TestNextJsDetectorTags: @pytest.mark.parametrize("verb", ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]) diff --git a/tests/test_dispatch_resolution.py b/tests/test_dispatch_resolution.py new file mode 100644 index 0000000..75f6d58 --- /dev/null +++ b/tests/test_dispatch_resolution.py @@ -0,0 +1,38 @@ +"""Regression tests for conservative TypeScript interface dispatch.""" + +from __future__ import annotations + +from pathlib import Path + +from trailmark.models.edges import EdgeConfidence, EdgeKind +from trailmark.parse import parse_file + + +def test_constructor_assigned_interface_dispatch_resolves(tmp_path: Path) -> None: + source = tmp_path / "handler.ts" + source.write_text( + "interface Handler { process(): void }\n" + "class HandlerImpl implements Handler { process(): void {} }\n" + "function entry(): void {\n" + " const handler: Handler = new HandlerImpl();\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 + ) + assert edge.target_id == "handler:HandlerImpl.process" + assert edge.confidence == EdgeConfidence.INFERRED + + +def test_dynamic_property_dispatch_remains_unresolved(tmp_path: Path) -> None: + source = tmp_path / "dynamic.ts" + source.write_text("function entry(handlers: any, key: string) { handlers[key](); }\n") + graph = parse_file(str(source), "typescript") + assert not any( + edge.source_id == "dynamic:entry" and "HandlerImpl" in edge.target_id + for edge in graph.edges + ) diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index f541ac3..a5b1ed6 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -501,14 +501,7 @@ def test_fallback_and_receive_detected_when_parser_emits_them( self, tmp_path: Path, ) -> None: - """Guard against regressions once the Solidity parser exposes special fns. - - Today the Solidity parser does not emit ``receive()`` / ``fallback()`` - as separate function nodes, so the detector has nothing to tag. When - the parser is updated to emit them, this test should start passing - without any detector changes — the _SOL_SPECIAL regex already handles - the signature shape. - """ + """Receive and fallback functions are concrete external entrypoints.""" (tmp_path / "Wallet.sol").write_text( "// SPDX-License-Identifier: MIT\n" "pragma solidity ^0.8.0;\n" @@ -519,9 +512,8 @@ def test_fallback_and_receive_detected_when_parser_emits_them( ) engine = QueryEngine.from_directory(str(tmp_path), language="solidity") ids = {ep["node_id"] for ep in engine.attack_surface()} - # Current expectation: parser does not emit these yet. - assert not any("receive" in nid for nid in ids) - assert not any("fallback" in nid for nid in ids) + assert "Wallet:Wallet.receive" in ids + assert "Wallet:Wallet.fallback" in ids class TestJavaScriptFrameworks: diff --git a/tests/test_kat_parsers.py b/tests/test_kat_parsers.py index 086550f..892d953 100644 --- a/tests/test_kat_parsers.py +++ b/tests/test_kat_parsers.py @@ -51,6 +51,7 @@ ("sway", "taxonomy"), ("tact", "taxonomy"), ("thrift", "taxonomy"), + ("sql", "taxonomy"), ("typescript", "taxonomy"), ] diff --git a/tests/test_native_stability.py b/tests/test_native_stability.py new file mode 100644 index 0000000..2631328 --- /dev/null +++ b/tests/test_native_stability.py @@ -0,0 +1,54 @@ +"""Subprocess regressions for tree-sitter native crashes on larger inputs.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.mark.parametrize( + ("language", "suffix", "source"), + [ + ("go", ".go", "package p\n" + "".join(f"func f{i}() {{}}\n" for i in range(1800))), + ("rust", ".rs", "".join(f"fn f{i}() {{}}\n" for i in range(1800))), + ( + "typescript", + ".ts", + "".join(f"export function f{i}(): number {{ return {i}; }}\n" for i in range(900)), + ), + ( + "solidity", + ".sol", + "pragma solidity ^0.8.0; contract Large {\n" + + "".join( + f"function f{i}() external pure returns(uint) {{ return {i}; }}\n" + for i in range(500) + ) + + "}\n", + ), + ], +) +def test_large_file_parses_in_subprocess( + tmp_path: Path, + language: str, + suffix: str, + source: str, +) -> None: + path = tmp_path / f"large{suffix}" + path.write_text(source) + script = ( + "from trailmark.parse import parse_file; " + f"g=parse_file({str(path)!r}, {language!r}); " + "assert g.nodes" + ) + result = subprocess.run( # noqa: S603 + [sys.executable, "-X", "faulthandler", "-c", script], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/test_package_metadata.py b/tests/test_package_metadata.py index 8426c20..6f874d9 100644 --- a/tests/test_package_metadata.py +++ b/tests/test_package_metadata.py @@ -6,10 +6,10 @@ from pathlib import Path -def test_tree_sitter_language_pack_pins_modern_series() -> None: +def test_tree_sitter_language_pack_uses_platform_trust_series() -> None: pyproject = tomllib.loads((Path(__file__).parents[1] / "pyproject.toml").read_text()) dependencies = pyproject["project"]["dependencies"] dependency = next(dep for dep in dependencies if dep.startswith("tree-sitter-language-pack")) - assert ">=1.8" in dependency + assert ">=1.9" in dependency assert "<2.0" in dependency diff --git a/tests/test_repository_links.py b/tests/test_repository_links.py new file mode 100644 index 0000000..a46b537 --- /dev/null +++ b/tests/test_repository_links.py @@ -0,0 +1,67 @@ +"""Tests for explicit cross-language and external graph links.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from trailmark.models.edges import EdgeConfidence, EdgeKind +from trailmark.models.nodes import NodeOrigin +from trailmark.parse import parse_directory +from trailmark.query.api import QueryEngine + + +def _write_sources(tmp_path: Path) -> None: + (tmp_path / "caller.py").write_text("def invoke():\n return 1\n") + (tmp_path / "callee.rs").write_text("fn execute() {}\n") + (tmp_path / ".trailmark").mkdir() + + +def test_links_cross_language_nodes(tmp_path: Path) -> None: + _write_sources(tmp_path) + (tmp_path / ".trailmark" / "links.toml").write_text( + '[[link]]\nsource = "caller:invoke"\ntarget = "callee:execute"\n' + 'confidence = "certain"\ndescription = "host invokes contract"\n', + ) + graph = parse_directory(str(tmp_path), "python,rust") + edge = next( + edge + for edge in graph.edges + if edge.source_id == "caller:invoke" and edge.target_id == "callee:execute" + ) + assert edge.kind == EdgeKind.CALLS + assert edge.confidence == EdgeConfidence.CERTAIN + assert ("description", "host invokes contract") in edge.attributes + engine = QueryEngine.from_directory(str(tmp_path), "python,rust") + assert [unit["id"] for unit in engine.callees_of("caller:invoke")] == ["callee:execute"] + + +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"): + parse_directory(str(tmp_path), "python") + + config.write_text( + '[[link]]\nsource = "caller:invoke"\ntarget = "rpc:transfer"\nexternal = true\n', + ) + graph = parse_directory(str(tmp_path), "python") + proxy = graph.nodes["proxy.external:rpc:transfer"] + assert proxy.origin == NodeOrigin.PROXY + + +@pytest.mark.parametrize( + "body,match", + [ + ("link = {}\n", "'link' must be an array"), + ('[[link]]\nsource = "invoke"\n', "'target' must be"), + ('[[link]]\nsource = "invoke"\ntarget = "execute"\nkind = "invalid"\n', "invalid kind"), + ], +) +def test_invalid_links_are_rejected(tmp_path: Path, body: str, match: str) -> None: + _write_sources(tmp_path) + (tmp_path / ".trailmark" / "links.toml").write_text(body) + with pytest.raises(ValueError, match=match): + parse_directory(str(tmp_path), "python,rust") diff --git a/tests/test_sql_parser.py b/tests/test_sql_parser.py new file mode 100644 index 0000000..785a3ea --- /dev/null +++ b/tests/test_sql_parser.py @@ -0,0 +1,99 @@ +"""Tests for PostgreSQL-oriented SQL graph extraction.""" + +from __future__ import annotations + +from pathlib import Path + +from trailmark.models.edges import EdgeKind +from trailmark.models.nodes import NodeKind +from trailmark.parse import detect_languages, parse_directory, parse_file, supported_languages + + +def test_sql_schema_routines_and_dependencies(tmp_path: Path) -> None: + source = tmp_path / "schema.sql" + source.write_text( + "CREATE SCHEMA app;\n" + "CREATE TABLE app.users (id bigint);\n" + "CREATE VIEW app.active AS SELECT id FROM app.users;\n" + "CREATE FUNCTION app.lookup(x int) RETURNS bigint LANGUAGE SQL " + "AS $$ SELECT id FROM app.users $$;\n" + "CREATE PROCEDURE app.refresh() LANGUAGE SQL AS $$ SELECT id FROM app.users $$;\n", + ) + + graph = parse_file(str(source), "sql") + + assert graph.nodes["schema:app"].kind == NodeKind.SCHEMA + assert graph.nodes["schema:app.users"].kind == NodeKind.TABLE + assert graph.nodes["schema:app.active"].kind == NodeKind.VIEW + assert graph.nodes["schema:app.lookup"].kind == NodeKind.FUNCTION + assert graph.nodes["schema:app.refresh"].kind == NodeKind.PROCEDURE + assert graph.nodes["schema:app.lookup"].parameters[0].name == "x" + dependencies = { + (edge.source_id, edge.target_id) + for edge in graph.edges + if edge.kind == EdgeKind.CORRESPONDS_TO + } + assert ("schema:app.active", "schema:app.users") in dependencies + assert ("schema:app.lookup", "schema:app.users") in dependencies + assert ("schema:app.refresh", "schema:app.users") in dependencies + + +def test_sql_is_registered_and_auto_detected(tmp_path: Path) -> None: + (tmp_path / "schema.sql").write_text("CREATE TABLE users (id bigint);") + assert "sql" in supported_languages() + assert "sql" in detect_languages(str(tmp_path)) + + +def test_malformed_sql_returns_partial_graph(tmp_path: Path) -> None: + source = tmp_path / "broken.sql" + source.write_text("CREATE TABLE users (id bigint") + graph = parse_file(str(source), "sql") + assert graph.language == "sql" + assert any(node.kind == NodeKind.MODULE for node in graph.nodes.values()) + + +def test_cross_file_dependency_keeps_real_table(tmp_path: Path) -> None: + (tmp_path / "a_table.sql").write_text("CREATE TABLE app.users (id bigint);") + (tmp_path / "z_view.sql").write_text("CREATE VIEW app.active AS SELECT id FROM app.users;") + graph = parse_directory(str(tmp_path), "sql") + assert graph.nodes["a_table:app.users"].kind == NodeKind.TABLE + assert any( + edge.source_id == "z_view:app.active" + and edge.target_id == "a_table:app.users" + and edge.kind == EdgeKind.CORRESPONDS_TO + for edge in graph.edges + ) + assert not any( + node.kind == NodeKind.PROXY and node.name.endswith("app.users") + for node in graph.nodes.values() + ) + + +def test_single_file_unresolved_dependency_is_materialized(tmp_path: Path) -> None: + source = tmp_path / "views.sql" + source.write_text("CREATE VIEW app.active AS SELECT id FROM app.users;") + graph = parse_file(str(source), "sql") + + edge = next( + edge + for edge in graph.edges + if edge.source_id == "views:app.active" + and edge.target_id == "views:app.users" + and edge.kind == EdgeKind.CORRESPONDS_TO + ) + assert graph.nodes[edge.target_id].kind == NodeKind.PROXY + + +def test_sql_function_invocation_is_not_relation_dependency(tmp_path: Path) -> None: + source = tmp_path / "functions.sql" + source.write_text( + "CREATE TABLE app.events (id bigint);\n" + "CREATE FUNCTION app.event_count() RETURNS bigint LANGUAGE SQL " + "AS $$ SELECT count(*) 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 "functions:app.events" in dependencies + assert "functions:count" not in dependencies + assert "functions:count" not in graph.nodes diff --git a/uv.lock b/uv.lock index f7b7b97..f567461 100644 --- a/uv.lock +++ b/uv.lock @@ -599,12 +599,13 @@ wheels = [ [[package]] name = "trailmark" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "rustworkx" }, { name = "tree-sitter" }, { name = "tree-sitter-language-pack" }, + { name = "tree-sitter-sql" }, ] [package.dev-dependencies] @@ -619,8 +620,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "rustworkx", specifier = ">=0.17,<1.0" }, - { name = "tree-sitter", specifier = ">=0.25,<1.0" }, - { name = "tree-sitter-language-pack", specifier = ">=1.8,<2.0" }, + { name = "tree-sitter", specifier = "~=0.25.0" }, + { name = "tree-sitter-language-pack", specifier = ">=1.9,<2.0" }, + { name = "tree-sitter-sql", specifier = ">=0.3.11,<0.4" }, ] [package.metadata.requires-dev] @@ -663,19 +665,35 @@ wheels = [ [[package]] name = "tree-sitter-language-pack" -version = "1.8.1" +version = "1.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tree-sitter" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/f3/fc0eabffb192ce49abee47e0bb3fea70fecfeacc1b8159e6f069795d2294/tree_sitter_language_pack-1.8.1.tar.gz", hash = "sha256:800f8a92a2238e1b3abcc25cc86fd3a1b1d3392263c49266640c1b079a8e625e", size = 109775, upload-time = "2026-05-15T09:48:32.281Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/d0/06624b30acf3100ed663d604ac893882beafde603971e76f1160808ae02c/tree_sitter_language_pack-1.12.5.tar.gz", hash = "sha256:e6ca8fd7f1cc24496f2fff73156d0ce20284ce56e18a84f4c0c10388a1e4c951", size = 81955, upload-time = "2026-07-07T12:58:02.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/b9/015240f1e091f0d7608582e2930b62580839f4b3e94b5813bc1894017cf3/tree_sitter_language_pack-1.12.5-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c3613f26f1660d757626b50e2f7fd50ce269193ec9e8e0ad699ae47bf03fd664", size = 2140086, upload-time = "2026-07-07T12:57:53.574Z" }, + { url = "https://files.pythonhosted.org/packages/08/fd/8ca53b11ea1058c920944b52b41c4c4d96ccf67c62b15174f6d3d2a0126f/tree_sitter_language_pack-1.12.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:30010156a06179e7c179ae51ba6f2da148bd0bc364f750bcc5ef81ab1c4e0c53", size = 2031270, upload-time = "2026-07-07T12:57:55.165Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/af79d0d302f1f47f190fd1f09146a6e5f88a32a7c0d0759820d7ca9aafc3/tree_sitter_language_pack-1.12.5-cp310-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1609b9cc2518e4657f6ef80a8ac533a8f5a04d4a74135d744fb171eca434e6eb", size = 2189197, upload-time = "2026-07-07T12:57:56.37Z" }, + { url = "https://files.pythonhosted.org/packages/25/da/6e3db4ad85802747ead34f46f402508d796cd11be65b79d6ba6d76281a3f/tree_sitter_language_pack-1.12.5-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:de5ec0c1b184176443fc6093f07fe5a1c403ee51ec8593bde372bd5960819941", size = 2299397, upload-time = "2026-07-07T12:57:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/ac271b8013ff4f386ddce86cab080b8c6352d3efa45e8b2285d246eed318/tree_sitter_language_pack-1.12.5-cp310-abi3-win_amd64.whl", hash = "sha256:eb58ecee144247d8cde52a567bbe5c6f950ed1d2c416295e00f0e31389f1751e", size = 2073479, upload-time = "2026-07-07T12:57:59.491Z" }, + { url = "https://files.pythonhosted.org/packages/79/80/fbe56f84daec7fec163023f6e8865969e8873ec42b85241c750953320e1f/tree_sitter_language_pack-1.12.5-cp310-abi3-win_arm64.whl", hash = "sha256:41399bdd45eb4ced5840ddf1d44aa02fe69763c867bd7286aa20d182bfe397af", size = 1984602, upload-time = "2026-07-07T12:58:00.918Z" }, +] + +[[package]] +name = "tree-sitter-sql" +version = "0.3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/5c/3d10387f779f36835486167253682f61d5f4fd8336b7001da1ac7d78f31c/tree_sitter_sql-0.3.11.tar.gz", hash = "sha256:700b93be2174c3c83d174ec3e10b682f72a4fb451f0076c7ce5012f1d5a76cbc", size = 834454, upload-time = "2025-10-01T13:44:15.913Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/9c/a9faacacdec92026b88bceeb08646be7c32edf05796e6f19d00873cdc1e4/tree_sitter_language_pack-1.8.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b0e3cd56dc359dbd623cc90df09494be4de1605628e53f92b981cd78a7de1a81", size = 2083265, upload-time = "2026-05-15T09:48:19.532Z" }, - { url = "https://files.pythonhosted.org/packages/7c/44/aded4a03575c8b010b03d574fadb5dc705a898a1d940670014634b1d819e/tree_sitter_language_pack-1.8.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a5c378bf1b920fa2470a668a7bf20b2e99a9288641bf6a04c5cfb8051fe91ea1", size = 1943468, upload-time = "2026-05-15T09:48:21.788Z" }, - { url = "https://files.pythonhosted.org/packages/37/e4/732e445d4341e86473c082a9debbca7c46de17ac935284c7060202b068ba/tree_sitter_language_pack-1.8.1-cp310-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fa15da867ca257353fa4a709fb6c10f2fcc4b8c0899f27b883872c2feeaac61e", size = 2085933, upload-time = "2026-05-15T09:48:24.02Z" }, - { url = "https://files.pythonhosted.org/packages/b5/20/6f0c5b2b40de5a38134a1810b718775b960170b841bd079bd85d8e5b6616/tree_sitter_language_pack-1.8.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:79c5a3ce9a912dfddd08147cc14c91f7baa152ca18d39591eb3db8471e42400d", size = 2197368, upload-time = "2026-05-15T09:48:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/a9/35/c2a05ba39a65008a3c87dcf238ac3c164d349f504b1d8a03295dbdb5e6c7/tree_sitter_language_pack-1.8.1-cp310-abi3-win_amd64.whl", hash = "sha256:572a8c0b1528bd150366399f04deb9e8c8d30c5b5a3ee7dabca898c4eab22a9a", size = 1993818, upload-time = "2026-05-15T09:48:28.228Z" }, - { url = "https://files.pythonhosted.org/packages/50/1b/6c59695ac025bded36d2a099c165cbb95c819b6cfa075229835cdb4211b7/tree_sitter_language_pack-1.8.1-cp310-abi3-win_arm64.whl", hash = "sha256:018d0a60eeb8de1aa79ac5ed1cba42643d93d4da140c77640ac1576b541e68b9", size = 1902157, upload-time = "2026-05-15T09:48:30.203Z" }, + { url = "https://files.pythonhosted.org/packages/32/68/bb80073915dfe1b38935451bc0d65528666c126b2d5878e7140ef9bf9f8a/tree_sitter_sql-0.3.11-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cf1b0c401756940bf47544ad7c4cc97373fc0dac118f821820953e7015a115e3", size = 322035, upload-time = "2025-10-01T13:44:07.497Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/b2bd5f9919ea15c4ae90a156999101ebd4caa4036babe54efaf9d3e77d55/tree_sitter_sql-0.3.11-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a33cd6880ab2debef036f80365c32becb740ec79946805598488732b6c515fff", size = 341635, upload-time = "2025-10-01T13:44:08.961Z" }, + { url = "https://files.pythonhosted.org/packages/8e/96/7cee5661aa897e5d1a67499944ea5cf8a148953c1dc07a3059a50db8cb56/tree_sitter_sql-0.3.11-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:344e99b59c8c8d72f7154041e9d054400f4a3fccc16c2c96ac106dde0e7f8d0c", size = 381217, upload-time = "2025-10-01T13:44:10.211Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c1/eec7c09a9c94436ea4c56d096feba815e42b209b3d41a17532f99ecf0c67/tree_sitter_sql-0.3.11-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5128b12f71ac0f5ebcc607f67a62cdc56a187c1a5ba7553feeb9c5f6f9bc3c72", size = 380606, upload-time = "2025-10-01T13:44:11.135Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/06e9598799bd119e56f6e431d42c2f3a5c6dee858a5b6ad7633cc4d670aa/tree_sitter_sql-0.3.11-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03cc164fcf7b1f711e7d939aeb4d1f62c76f4162e081c70b860b4fcd91806a38", size = 380862, upload-time = "2025-10-01T13:44:12.072Z" }, + { url = "https://files.pythonhosted.org/packages/52/e9/a7afd7f68ce165c040ce50e67bb05553784a8e17f37e057405d693fc869d/tree_sitter_sql-0.3.11-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0e22ea8de690dd9960d8c0c36c4cd25417b084e1e29c91ac0235fbdb3abb4664", size = 379447, upload-time = "2025-10-01T13:44:13.062Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b3/57ff42dadd33c06fabe6c725de50e1625e1060f1571cc21a9260febadc1f/tree_sitter_sql-0.3.11-cp310-abi3-win_amd64.whl", hash = "sha256:c57b877702d218c0856592d33320c02b2dc8411d8820b3bf7b81be86c54fa0bb", size = 343550, upload-time = "2025-10-01T13:44:13.988Z" }, + { url = "https://files.pythonhosted.org/packages/77/60/f10b8551f435d57a4748820ee30e66df2682820b2972375c2b89d2e5fb10/tree_sitter_sql-0.3.11-cp310-abi3-win_arm64.whl", hash = "sha256:8a1e42f0a2c9b01b23074708ecf5b8d21b9a0440e3dff279d8cf466cdf1a877e", size = 333547, upload-time = "2025-10-01T13:44:14.893Z" }, ] [[package]]