trailmark 0.2.2__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. {trailmark-0.2.2 → trailmark-0.3.1}/.github/workflows/ci.yml +2 -2
  2. {trailmark-0.2.2 → trailmark-0.3.1}/.github/workflows/mutation.yml +1 -1
  3. {trailmark-0.2.2 → trailmark-0.3.1}/.github/workflows/publish.yml +1 -1
  4. {trailmark-0.2.2 → trailmark-0.3.1}/CONTRIBUTING.md +7 -2
  5. {trailmark-0.2.2 → trailmark-0.3.1}/PKG-INFO +8 -3
  6. {trailmark-0.2.2 → trailmark-0.3.1}/README.md +6 -1
  7. {trailmark-0.2.2 → trailmark-0.3.1}/pyproject.toml +2 -2
  8. trailmark-0.3.1/src/trailmark/__init__.py +16 -0
  9. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/analysis/preanalysis.py +3 -3
  10. trailmark-0.3.1/src/trailmark/parse.py +216 -0
  11. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/_common.py +67 -2
  12. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/c/parser.py +14 -0
  13. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/cpp/parser.py +8 -3
  14. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/erlang/parser.py +25 -2
  15. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/query/api.py +5 -178
  16. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/storage/graph_store.py +24 -10
  17. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_c_parser.py +60 -0
  18. trailmark-0.3.1/tests/test_common_parser.py +320 -0
  19. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_hidden_apis.py +3 -7
  20. trailmark-0.3.1/tests/test_package_metadata.py +16 -0
  21. trailmark-0.3.1/tests/test_parse_api.py +106 -0
  22. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_polyglot.py +13 -1
  23. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_preanalysis.py +18 -1
  24. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_readme.py +16 -5
  25. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_storage.py +39 -0
  26. {trailmark-0.2.2 → trailmark-0.3.1}/uv.lock +8 -8
  27. trailmark-0.2.2/src/trailmark/__init__.py +0 -3
  28. trailmark-0.2.2/tests/test_common_parser.py +0 -154
  29. {trailmark-0.2.2 → trailmark-0.3.1}/.github/dependabot.yml +0 -0
  30. {trailmark-0.2.2 → trailmark-0.3.1}/.github/workflows/semgrep.yml +0 -0
  31. {trailmark-0.2.2 → trailmark-0.3.1}/.github/workflows/zizmor.yml +0 -0
  32. {trailmark-0.2.2 → trailmark-0.3.1}/.gitignore +0 -0
  33. {trailmark-0.2.2 → trailmark-0.3.1}/AGENTS.md +0 -0
  34. {trailmark-0.2.2 → trailmark-0.3.1}/LICENSE +0 -0
  35. {trailmark-0.2.2 → trailmark-0.3.1}/NOTICE +0 -0
  36. {trailmark-0.2.2 → trailmark-0.3.1}/docs/entrypoint-patterns.md +0 -0
  37. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/analysis/__init__.py +0 -0
  38. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/analysis/augment.py +0 -0
  39. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/analysis/diff.py +0 -0
  40. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/analysis/entrypoints.py +0 -0
  41. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/cli.py +0 -0
  42. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/diagram.py +0 -0
  43. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/models/__init__.py +0 -0
  44. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/models/annotations.py +0 -0
  45. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/models/edges.py +0 -0
  46. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/models/graph.py +0 -0
  47. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/models/nodes.py +0 -0
  48. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/__init__.py +0 -0
  49. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/base.py +0 -0
  50. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/c/__init__.py +0 -0
  51. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/cairo/__init__.py +0 -0
  52. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/cairo/parser.py +0 -0
  53. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/circom/__init__.py +0 -0
  54. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/circom/parser.py +0 -0
  55. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/cpp/__init__.py +0 -0
  56. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/csharp/__init__.py +0 -0
  57. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/csharp/parser.py +0 -0
  58. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/dart/__init__.py +0 -0
  59. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/dart/parser.py +0 -0
  60. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/erlang/__init__.py +0 -0
  61. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/go/__init__.py +0 -0
  62. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/go/parser.py +0 -0
  63. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/haskell/__init__.py +0 -0
  64. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/haskell/parser.py +0 -0
  65. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/java/__init__.py +0 -0
  66. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/java/parser.py +0 -0
  67. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/javascript/__init__.py +0 -0
  68. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/javascript/parser.py +0 -0
  69. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/kotlin/__init__.py +0 -0
  70. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/kotlin/parser.py +0 -0
  71. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/masm/__init__.py +0 -0
  72. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/masm/parser.py +0 -0
  73. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/objc/__init__.py +0 -0
  74. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/objc/parser.py +0 -0
  75. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/php/__init__.py +0 -0
  76. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/php/parser.py +0 -0
  77. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/python/__init__.py +0 -0
  78. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/python/parser.py +0 -0
  79. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/ruby/__init__.py +0 -0
  80. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/ruby/parser.py +0 -0
  81. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/rust/__init__.py +0 -0
  82. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/rust/parser.py +0 -0
  83. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/solidity/__init__.py +0 -0
  84. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/solidity/parser.py +0 -0
  85. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/swift/__init__.py +0 -0
  86. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/swift/parser.py +0 -0
  87. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/typescript/__init__.py +0 -0
  88. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/parsers/typescript/parser.py +0 -0
  89. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/py.typed +0 -0
  90. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/query/__init__.py +0 -0
  91. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/storage/__init__.py +0 -0
  92. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/__init__.py +0 -0
  93. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/circom/LICENSE +0 -0
  94. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/circom/__init__.py +0 -0
  95. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/circom/binding.c +0 -0
  96. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/circom/grammar.js +0 -0
  97. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/circom/src/parser.c +0 -0
  98. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/circom/src/tree_sitter/parser.h +0 -0
  99. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/masm/LICENSE +0 -0
  100. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/masm/__init__.py +0 -0
  101. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/masm/binding.c +0 -0
  102. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/masm/grammar.js +0 -0
  103. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/masm/src/parser.c +0 -0
  104. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/masm/src/tree_sitter/alloc.h +0 -0
  105. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/masm/src/tree_sitter/array.h +0 -0
  106. {trailmark-0.2.2 → trailmark-0.3.1}/src/trailmark/tree_sitter_custom/masm/src/tree_sitter/parser.h +0 -0
  107. {trailmark-0.2.2 → trailmark-0.3.1}/tests/__init__.py +0 -0
  108. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_augment.py +0 -0
  109. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_cairo_parser.py +0 -0
  110. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_circom_grammar.py +0 -0
  111. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_circom_parser.py +0 -0
  112. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_cli.py +0 -0
  113. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_cli_parser.py +0 -0
  114. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_cpp_parser.py +0 -0
  115. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_csharp_parser.py +0 -0
  116. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_dart_parser.py +0 -0
  117. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_detector_tags.py +0 -0
  118. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_diagram_script.py +0 -0
  119. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_diff.py +0 -0
  120. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_entrypoints.py +0 -0
  121. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_erlang_parser.py +0 -0
  122. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_go_parser.py +0 -0
  123. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_haskell_parser.py +0 -0
  124. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_java_parser.py +0 -0
  125. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_javascript_parser.py +0 -0
  126. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_kotlin_parser.py +0 -0
  127. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_masm_parser.py +0 -0
  128. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_models.py +0 -0
  129. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_objc_parser.py +0 -0
  130. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_parser_edge_cases.py +0 -0
  131. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_php_parser.py +0 -0
  132. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_property_based.py +0 -0
  133. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_python_parser.py +0 -0
  134. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_query.py +0 -0
  135. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_query_edge_cases.py +0 -0
  136. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_ruby_parser.py +0 -0
  137. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_rust_parser.py +0 -0
  138. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_solidity_parser.py +0 -0
  139. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_storage_edge_cases.py +0 -0
  140. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_swift_parser.py +0 -0
  141. {trailmark-0.2.2 → trailmark-0.3.1}/tests/test_typescript_parser.py +0 -0
@@ -38,7 +38,7 @@ jobs:
38
38
  with:
39
39
  persist-credentials: false
40
40
 
41
- - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
41
+ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
42
42
 
43
43
  - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
44
44
  with:
@@ -59,7 +59,7 @@ jobs:
59
59
  with:
60
60
  persist-credentials: false
61
61
 
62
- - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
62
+ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
63
63
 
64
64
  - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
65
65
  with:
@@ -22,7 +22,7 @@ jobs:
22
22
  with:
23
23
  persist-credentials: false
24
24
 
25
- - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
25
+ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
26
26
 
27
27
  - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
28
28
  with:
@@ -17,7 +17,7 @@ jobs:
17
17
  with:
18
18
  persist-credentials: false
19
19
 
20
- - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
20
+ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
21
21
 
22
22
  - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
23
23
  with:
@@ -50,8 +50,12 @@ Linux (CI runs on Ubuntu without issue).
50
50
 
51
51
  ## Architecture
52
52
 
53
- Trailmark has a three-layer architecture:
53
+ Trailmark has a parse layer followed by a three-layer analysis stack:
54
54
 
55
+ 0. **Parse API** (`src/trailmark/parse.py`) -- public entry point for
56
+ parsing files or directories into a raw `CodeGraph`. Lazily imports
57
+ the appropriate language parser. Used directly for parse-only
58
+ workflows and internally by `QueryEngine`.
55
59
  1. **CodeGraph** (`src/trailmark/models/graph.py`) -- mutable data
56
60
  container holding nodes, edges, annotations, and entrypoints.
57
61
  2. **GraphStore** (`src/trailmark/storage/graph_store.py`) -- wraps
@@ -75,6 +79,7 @@ Trailmark has a three-layer architecture:
75
79
 
76
80
  1. Create `src/trailmark/parsers/<lang>/parser.py` implementing the
77
81
  `BaseParser` protocol from `src/trailmark/parsers/base.py`.
78
- 2. Register the language in `src/trailmark/parsers/__init__.py`.
82
+ 2. Register the parser in `_PARSER_MAP` and its file extensions in
83
+ `_LANGUAGE_EXTENSIONS`, both in `src/trailmark/parse.py`.
79
84
  3. Add tests in `tests/test_<lang>_parser.py`.
80
85
  4. Update the language table in `README.md`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trailmark
3
- Version: 0.2.2
3
+ Version: 0.3.1
4
4
  Summary: Parse source code into a queryable graph of functions, classes, calls, and semantic annotations
5
5
  Project-URL: Homepage, https://github.com/trailofbits/trailmark
6
6
  Project-URL: Repository, https://github.com/trailofbits/trailmark
@@ -11,7 +11,7 @@ License-File: LICENSE
11
11
  License-File: NOTICE
12
12
  Requires-Python: >=3.12
13
13
  Requires-Dist: rustworkx<1.0,>=0.17
14
- Requires-Dist: tree-sitter-language-pack<2.0,>=0.13
14
+ Requires-Dist: tree-sitter-language-pack!=1.6.3,<1.7,>=0.13
15
15
  Requires-Dist: tree-sitter<1.0,>=0.25
16
16
  Description-Content-Type: text/markdown
17
17
 
@@ -107,7 +107,7 @@ flowchart TD
107
107
  class CG data
108
108
  ```
109
109
 
110
- Node IDs follow the scheme `module:function`, `module:Class`, or `module:Class.method` for unambiguous lookup. Edge confidence is tagged as `certain` (direct calls, `self.method()`), `inferred` (attribute access on non-self objects), or `uncertain` (dynamic dispatch).
110
+ Node IDs follow the scheme `module:function`, `module:Class`, or `module:Class.method` for unambiguous lookup. Directory parsing resolves bare cross-file calls when a unique definition exists; ambiguous cross-file calls are left at their original best-effort target and marked `uncertain`. Edge confidence is tagged as `certain` (direct calls, `self.method()`), `inferred` (attribute access on non-self objects), or `uncertain` (dynamic dispatch or ambiguous resolution).
111
111
 
112
112
  ### 2. Index
113
113
 
@@ -384,8 +384,13 @@ See [docs/entrypoint-patterns.md](docs/entrypoint-patterns.md) for the full refe
384
384
  ### Programmatic API
385
385
 
386
386
  ```python
387
+ from trailmark.parse import parse_directory, parse_file
387
388
  from trailmark.query.api import QueryEngine
388
389
 
390
+ # Parse-only API: get the raw CodeGraph without building GraphStore/QueryEngine.
391
+ graph = parse_file("path/to/file.py")
392
+ graph = parse_directory("path/to/project", language="auto")
393
+
389
394
  # Single-language (default) or auto-detect + merge across all languages
390
395
  engine = QueryEngine.from_directory("path/to/project")
391
396
  engine = QueryEngine.from_directory("path/to/project", language="auto")
@@ -90,7 +90,7 @@ flowchart TD
90
90
  class CG data
91
91
  ```
92
92
 
93
- Node IDs follow the scheme `module:function`, `module:Class`, or `module:Class.method` for unambiguous lookup. Edge confidence is tagged as `certain` (direct calls, `self.method()`), `inferred` (attribute access on non-self objects), or `uncertain` (dynamic dispatch).
93
+ Node IDs follow the scheme `module:function`, `module:Class`, or `module:Class.method` for unambiguous lookup. Directory parsing resolves bare cross-file calls when a unique definition exists; ambiguous cross-file calls are left at their original best-effort target and marked `uncertain`. Edge confidence is tagged as `certain` (direct calls, `self.method()`), `inferred` (attribute access on non-self objects), or `uncertain` (dynamic dispatch or ambiguous resolution).
94
94
 
95
95
  ### 2. Index
96
96
 
@@ -367,8 +367,13 @@ See [docs/entrypoint-patterns.md](docs/entrypoint-patterns.md) for the full refe
367
367
  ### Programmatic API
368
368
 
369
369
  ```python
370
+ from trailmark.parse import parse_directory, parse_file
370
371
  from trailmark.query.api import QueryEngine
371
372
 
373
+ # Parse-only API: get the raw CodeGraph without building GraphStore/QueryEngine.
374
+ graph = parse_file("path/to/file.py")
375
+ graph = parse_directory("path/to/project", language="auto")
376
+
372
377
  # Single-language (default) or auto-detect + merge across all languages
373
378
  engine = QueryEngine.from_directory("path/to/project")
374
379
  engine = QueryEngine.from_directory("path/to/project", language="auto")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "trailmark"
7
- version = "0.2.2"
7
+ version = "0.3.1"
8
8
  description = "Parse source code into a queryable graph of functions, classes, calls, and semantic annotations"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -14,7 +14,7 @@ authors = [
14
14
  ]
15
15
  dependencies = [
16
16
  "tree-sitter>=0.25,<1.0",
17
- "tree-sitter-language-pack>=0.13,<2.0",
17
+ "tree-sitter-language-pack>=0.13,<1.7,!=1.6.3",
18
18
  "rustworkx>=0.17,<1.0",
19
19
  ]
20
20
 
@@ -0,0 +1,16 @@
1
+ """Trailmark: Parse source code into queryable graphs for security analysis."""
2
+
3
+ from trailmark.parse import (
4
+ detect_languages,
5
+ parse_directory,
6
+ parse_file,
7
+ supported_languages,
8
+ )
9
+
10
+ __all__ = [
11
+ "detect_languages",
12
+ "parse_directory",
13
+ "parse_file",
14
+ "supported_languages",
15
+ ]
16
+ __version__ = "0.3.1"
@@ -78,9 +78,9 @@ def _compute_blast_radius(store: GraphStore) -> dict[str, Any]:
78
78
  Nodes above the threshold are collected in a subgraph.
79
79
  """
80
80
  graph = store._graph # noqa: SLF001
81
- digraph = store._digraph # noqa: SLF001
82
- id_to_idx = store._id_to_idx # noqa: SLF001
83
- idx_to_id = store._idx_to_id # noqa: SLF001
81
+ digraph = store._call_digraph # noqa: SLF001
82
+ id_to_idx = store._call_id_to_idx # noqa: SLF001
83
+ idx_to_id = store._call_idx_to_id # noqa: SLF001
84
84
 
85
85
  high_blast: set[str] = set()
86
86
  max_radius = 0
@@ -0,0 +1,216 @@
1
+ """Public parse-only API for building raw Trailmark code graphs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from trailmark.models.graph import CodeGraph
10
+ from trailmark.parsers._common import should_skip_dir
11
+ from trailmark.parsers.base import LanguageParser
12
+
13
+ _PARSER_MAP: dict[str, tuple[str, str]] = {
14
+ "python": ("trailmark.parsers.python", "PythonParser"),
15
+ "javascript": ("trailmark.parsers.javascript", "JavaScriptParser"),
16
+ "typescript": ("trailmark.parsers.typescript", "TypeScriptParser"),
17
+ "php": ("trailmark.parsers.php", "PHPParser"),
18
+ "ruby": ("trailmark.parsers.ruby", "RubyParser"),
19
+ "c": ("trailmark.parsers.c", "CParser"),
20
+ "cpp": ("trailmark.parsers.cpp", "CppParser"),
21
+ "c_sharp": ("trailmark.parsers.csharp", "CSharpParser"),
22
+ "java": ("trailmark.parsers.java", "JavaParser"),
23
+ "go": ("trailmark.parsers.go", "GoParser"),
24
+ "rust": ("trailmark.parsers.rust", "RustParser"),
25
+ "solidity": ("trailmark.parsers.solidity", "SolidityParser"),
26
+ "cairo": ("trailmark.parsers.cairo", "CairoParser"),
27
+ "circom": ("trailmark.parsers.circom", "CircomParser"),
28
+ "haskell": ("trailmark.parsers.haskell", "HaskellParser"),
29
+ "erlang": ("trailmark.parsers.erlang", "ErlangParser"),
30
+ "masm": ("trailmark.parsers.masm", "MasmParser"),
31
+ "swift": ("trailmark.parsers.swift", "SwiftParser"),
32
+ "objc": ("trailmark.parsers.objc", "ObjCParser"),
33
+ "kotlin": ("trailmark.parsers.kotlin", "KotlinParser"),
34
+ "dart": ("trailmark.parsers.dart", "DartParser"),
35
+ }
36
+
37
+ # Extensions used for language auto-detection. Keep these aligned with each
38
+ # parser's internal _EXTENSIONS tuple. Shared extensions (e.g., `.h` between C
39
+ # and C++) are handled by prioritizing the more specific language — C++ is
40
+ # tried before plain C when both report files.
41
+ _LANGUAGE_EXTENSIONS: dict[str, tuple[str, ...]] = {
42
+ "python": (".py",),
43
+ "javascript": (".js", ".jsx", ".mjs", ".cjs"),
44
+ "typescript": (".ts", ".tsx"),
45
+ "php": (".php",),
46
+ "ruby": (".rb",),
47
+ "c": (".c",),
48
+ "cpp": (".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"),
49
+ "c_sharp": (".cs",),
50
+ "java": (".java",),
51
+ "go": (".go",),
52
+ "rust": (".rs",),
53
+ "solidity": (".sol",),
54
+ "cairo": (".cairo",),
55
+ "circom": (".circom",),
56
+ "haskell": (".hs",),
57
+ "erlang": (".erl",),
58
+ "masm": (".masm",),
59
+ "swift": (".swift",),
60
+ # `.h` files can be C, ObjC, or C++; auto-detect only fires on .m/.mm.
61
+ # The parser itself still walks .h when invoked with language="objc".
62
+ "objc": (".m", ".mm"),
63
+ "kotlin": (".kt", ".kts"),
64
+ "dart": (".dart",),
65
+ }
66
+
67
+ _SUPPORTED_LANGUAGES = tuple(_PARSER_MAP.keys())
68
+
69
+
70
+ def supported_languages() -> tuple[str, ...]:
71
+ """Return the supported Trailmark parser language names."""
72
+ return _SUPPORTED_LANGUAGES
73
+
74
+
75
+ def parse_file(path: str, language: str | None = None) -> CodeGraph:
76
+ """Parse a single file into a raw ``CodeGraph``.
77
+
78
+ Args:
79
+ path: Source file to parse.
80
+ language: Optional explicit parser language. If omitted, language is
81
+ inferred from the file extension.
82
+
83
+ Raises:
84
+ ValueError: If the language is unsupported or cannot be inferred.
85
+ """
86
+ file_path = Path(path)
87
+ resolved_language = _resolve_file_language(file_path, language)
88
+ return _get_parser(resolved_language).parse_file(str(file_path))
89
+
90
+
91
+ def parse_directory(path: str, language: str = "python") -> CodeGraph:
92
+ """Parse a directory into a raw ``CodeGraph``.
93
+
94
+ ``language`` accepts a specific language name (e.g. ``"python"``),
95
+ ``"auto"`` to detect and merge all supported languages found under the
96
+ directory, or a comma-separated list like ``"python,rust"``.
97
+ """
98
+ languages = _resolve_directory_languages(path, language)
99
+ return _parse_and_merge(path, languages)
100
+
101
+
102
+ def detect_languages(path: str) -> list[str]:
103
+ """Return the sorted languages with at least one file under ``path``.
104
+
105
+ Detection walks the directory once, classifies each file by extension,
106
+ and returns the languages that have at least one match. Order is the
107
+ order languages are registered in ``_LANGUAGE_EXTENSIONS``, which
108
+ roughly corresponds to popularity and keeps deterministic behavior.
109
+ """
110
+ root = Path(path)
111
+ if not root.exists():
112
+ return []
113
+
114
+ ext_to_language: dict[str, str] = {}
115
+ for lang, exts in _LANGUAGE_EXTENSIONS.items():
116
+ for ext in exts:
117
+ # When languages share an extension (none currently do, but
118
+ # guard against it), the FIRST registration wins.
119
+ ext_to_language.setdefault(ext, lang)
120
+
121
+ found: set[str] = set()
122
+ for _dirpath, dirs, files in os.walk(root):
123
+ # Keep detection aligned with the parser walk rules.
124
+ dirs[:] = [d for d in dirs if not should_skip_dir(d)]
125
+ for name in files:
126
+ ext = _file_extension(name)
127
+ if ext in ext_to_language:
128
+ found.add(ext_to_language[ext])
129
+ if len(found) == len(_LANGUAGE_EXTENSIONS):
130
+ break
131
+
132
+ return [lang for lang in _LANGUAGE_EXTENSIONS if lang in found]
133
+
134
+
135
+ def _get_parser(language: str) -> LanguageParser:
136
+ """Lazily import and instantiate a parser for the given language."""
137
+ entry = _PARSER_MAP.get(language)
138
+ if entry is None:
139
+ msg = f"Unsupported language: {language}"
140
+ raise ValueError(msg)
141
+ module = importlib.import_module(entry[0])
142
+ cls = getattr(module, entry[1])
143
+ return cls()
144
+
145
+
146
+ def _resolve_file_language(path: Path, language: str | None) -> str:
147
+ """Resolve a single-file parse request to one concrete language."""
148
+ if language is None or language == "auto":
149
+ detected = _detect_file_language(path)
150
+ if detected is None:
151
+ msg = f"Could not infer supported language from file extension: {path}"
152
+ raise ValueError(msg)
153
+ return detected
154
+ if "," in language:
155
+ msg = f"Single-file parse requires one language, got: {language}"
156
+ raise ValueError(msg)
157
+ if language not in _PARSER_MAP:
158
+ msg = f"Unsupported language: {language}"
159
+ raise ValueError(msg)
160
+ return language
161
+
162
+
163
+ def _resolve_directory_languages(path: str, spec: str) -> list[str]:
164
+ """Expand a directory ``language`` argument into concrete languages."""
165
+ if spec == "auto":
166
+ detected = detect_languages(path)
167
+ if not detected:
168
+ msg = f"No supported languages detected under {path}"
169
+ raise ValueError(msg)
170
+ return detected
171
+ names = [name.strip() for name in spec.split(",") if name.strip()] if "," in spec else [spec]
172
+ for name in names:
173
+ if name not in _PARSER_MAP:
174
+ msg = f"Unsupported language: {name}"
175
+ raise ValueError(msg)
176
+ return names
177
+
178
+
179
+ def _parse_and_merge(path: str, languages: list[str]) -> CodeGraph:
180
+ """Parse ``path`` with each language parser and merge into one graph."""
181
+ if len(languages) == 1:
182
+ # Preserves pre-polyglot behavior exactly for the common case.
183
+ return _get_parser(languages[0]).parse_directory(path)
184
+
185
+ merged = CodeGraph(language="polyglot", root_path=str(Path(path).resolve()))
186
+ for lang in languages:
187
+ sub = _get_parser(lang).parse_directory(path)
188
+ merged.merge(sub)
189
+ # merge() doesn't touch `language`; preserve the polyglot marker.
190
+ merged.language = "polyglot"
191
+ return merged
192
+
193
+
194
+ def _detect_file_language(path: Path) -> str | None:
195
+ """Infer one supported language from a file extension."""
196
+ ext = _file_extension(path.name)
197
+ for language, exts in _LANGUAGE_EXTENSIONS.items():
198
+ if ext in exts:
199
+ return language
200
+ return None
201
+
202
+
203
+ def _file_extension(name: str) -> str:
204
+ """Return the lowercase extension including leading dot, or ``''``."""
205
+ dot = name.rfind(".")
206
+ if dot < 0:
207
+ return ""
208
+ return name[dot:].lower()
209
+
210
+
211
+ __all__ = [
212
+ "detect_languages",
213
+ "parse_directory",
214
+ "parse_file",
215
+ "supported_languages",
216
+ ]
@@ -2,13 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import dataclasses
5
6
  import os
6
7
  from collections.abc import Callable
7
8
  from contextvars import ContextVar
8
9
  from pathlib import Path
9
10
  from typing import TYPE_CHECKING
10
11
 
11
- from trailmark.models.edges import CodeEdge, EdgeKind
12
+ from trailmark.models.edges import CodeEdge, EdgeConfidence, EdgeKind
12
13
  from trailmark.models.graph import CodeGraph
13
14
  from trailmark.models.nodes import (
14
15
  BranchInfo,
@@ -104,6 +105,11 @@ _EXCLUDED_DIRS = frozenset(
104
105
  )
105
106
 
106
107
 
108
+ def should_skip_dir(dirname: str) -> bool:
109
+ """Return whether a directory should be skipped during source walks."""
110
+ return dirname in _EXCLUDED_DIRS or dirname.startswith(".")
111
+
112
+
107
113
  def walk_source_files(
108
114
  dir_path: str,
109
115
  extensions: tuple[str, ...],
@@ -114,7 +120,8 @@ def walk_source_files(
114
120
  and does not follow symlinks.
115
121
  """
116
122
  for root, dirs, files in os.walk(dir_path, followlinks=False):
117
- dirs[:] = [d for d in dirs if d not in _EXCLUDED_DIRS and not d.startswith(".")]
123
+ dirs[:] = [d for d in dirs if not should_skip_dir(d)]
124
+ dirs.sort()
118
125
  for fname in sorted(files):
119
126
  if any(fname.endswith(ext) for ext in extensions):
120
127
  yield os.path.join(root, fname)
@@ -142,6 +149,7 @@ def parse_directory(
142
149
  merged.merge(file_graph)
143
150
  finally:
144
151
  _DIRECTORY_PARSE_ROOT.reset(token)
152
+ _link_cross_file_calls(merged)
145
153
  return merged
146
154
 
147
155
 
@@ -291,3 +299,60 @@ def first_child_by_type(node: Node, type_name: str) -> Node | None:
291
299
  if child.type == type_name:
292
300
  return child
293
301
  return None
302
+
303
+
304
+ def _link_cross_file_calls(graph: CodeGraph) -> None:
305
+ """Rewrite dangling call edges to point at definitions in other modules.
306
+
307
+ Per-file parsers resolve bare calls like ``foo()`` as
308
+ ``current_module:foo``. After merging, if ``foo`` is defined in
309
+ another module, that edge target doesn't exist. This pass builds a
310
+ name index and rewrites those edges to the actual definition site.
311
+ """
312
+ defined_nodes = graph.nodes
313
+
314
+ name_to_ids: dict[str, list[str]] = {}
315
+ for node_id, unit in defined_nodes.items():
316
+ if unit.kind.value in {"function", "method"}:
317
+ name_to_ids.setdefault(unit.name, []).append(node_id)
318
+
319
+ new_edges: list[CodeEdge] = []
320
+ for edge in graph.edges:
321
+ if edge.kind != EdgeKind.CALLS or edge.target_id in defined_nodes:
322
+ new_edges.append(edge)
323
+ continue
324
+
325
+ target = edge.target_id
326
+ if "::" in target or "->" in target:
327
+ new_edges.append(edge)
328
+ continue
329
+
330
+ bare_name = target.rsplit(":", 1)[-1] if ":" in target else target
331
+ if "." in bare_name or "->" in bare_name:
332
+ new_edges.append(edge)
333
+ continue
334
+ candidates = name_to_ids.get(bare_name)
335
+
336
+ if not candidates:
337
+ new_edges.append(edge)
338
+ continue
339
+
340
+ if len(candidates) == 1:
341
+ new_edges.append(dataclasses.replace(edge, target_id=candidates[0]))
342
+ else:
343
+ # Ambiguous: prefer a single cross-file candidate when a same-file
344
+ # declaration is also present, but do not invent an arbitrary call
345
+ # target when multiple cross-file definitions share the same name.
346
+ src_module = edge.source_id.rsplit(":", 1)[0] if ":" in edge.source_id else ""
347
+ cross = [c for c in candidates if not c.startswith(src_module + ":")]
348
+ if len(cross) == 1:
349
+ new_edges.append(dataclasses.replace(edge, target_id=cross[0]))
350
+ else:
351
+ new_edges.append(
352
+ dataclasses.replace(
353
+ edge,
354
+ confidence=EdgeConfidence.UNCERTAIN,
355
+ )
356
+ )
357
+
358
+ graph.edges = new_edges
@@ -92,6 +92,17 @@ def _visit_module(
92
92
  _visit_top_level_node(child, file_path, module_id, graph)
93
93
 
94
94
 
95
+ _PREPROC_CONTAINER_TYPES = frozenset(
96
+ {
97
+ "preproc_if",
98
+ "preproc_ifdef",
99
+ "preproc_else",
100
+ "preproc_elif",
101
+ "ERROR",
102
+ }
103
+ )
104
+
105
+
95
106
  def _visit_top_level_node(
96
107
  child: Node,
97
108
  file_path: str,
@@ -109,6 +120,9 @@ def _visit_top_level_node(
109
120
  _extract_struct(child, file_path, module_id, graph)
110
121
  elif child.type == "enum_specifier":
111
122
  _extract_enum(child, file_path, module_id, graph)
123
+ elif child.type in _PREPROC_CONTAINER_TYPES:
124
+ for nested in child.children:
125
+ _visit_top_level_node(nested, file_path, module_id, graph)
112
126
 
113
127
 
114
128
  def _extract_type_def(
@@ -127,9 +127,14 @@ def _visit_top_level_node(
127
127
  module_id,
128
128
  graph,
129
129
  )
130
- elif child.type == "linkage_specification":
131
- # `extern "C" { ... }` wraps nested declarations — unwrap and
132
- # dispatch each child back through this visitor.
130
+ elif child.type in (
131
+ "linkage_specification",
132
+ "preproc_if",
133
+ "preproc_ifdef",
134
+ "preproc_else",
135
+ "preproc_elif",
136
+ "ERROR",
137
+ ):
133
138
  for nested in child.children:
134
139
  _visit_top_level_node(nested, file_path, module_id, class_id, graph)
135
140
  elif child.type == "class_specifier":
@@ -405,14 +405,37 @@ def _walk_calls(
405
405
  calls: list[tuple[str, Node]],
406
406
  ) -> None:
407
407
  """Recursively find call nodes and extract targets."""
408
+ if node.type == "remote":
409
+ name = _remote_call_name(node)
410
+ if name:
411
+ calls.append((name, node))
412
+ _walk_call_arguments(node.child_by_field_name("fun"), calls)
413
+ return
414
+
408
415
  if node.type == "call":
409
416
  name = _call_target_name(node)
410
417
  if name:
411
418
  calls.append((name, node))
419
+ _walk_call_arguments(node, calls)
420
+ return
421
+
412
422
  for child in node.children:
413
423
  _walk_calls(child, calls)
414
424
 
415
425
 
426
+ def _walk_call_arguments(
427
+ node: Node | None,
428
+ calls: list[tuple[str, Node]],
429
+ ) -> None:
430
+ """Walk a call's argument expressions without re-visiting its target."""
431
+ if node is None:
432
+ return
433
+ for i, child in enumerate(node.children):
434
+ if node.field_name_for_child(i) == "expr":
435
+ continue
436
+ _walk_calls(child, calls)
437
+
438
+
416
439
  def _call_target_name(node: Node) -> str:
417
440
  """Extract the function name from a call node."""
418
441
  expr = node.child_by_field_name("expr")
@@ -433,8 +456,8 @@ def _remote_call_name(remote: Node) -> str:
433
456
  return ""
434
457
  # remote_module has a module field containing the atom.
435
458
  mod_atom = mod_node.child_by_field_name("module")
436
- mod_name = node_text(mod_atom) if mod_atom is not None else ""
437
- fun_name = node_text(fun_node)
459
+ mod_name = node_text(mod_atom) if mod_atom is not None else node_text(mod_node).rstrip(":")
460
+ fun_name = _call_target_name(fun_node) if fun_node.type == "call" else node_text(fun_node)
438
461
  if mod_name and fun_name:
439
462
  return f"{mod_name}:{fun_name}"
440
463
  return ""