trailmark 0.2.1__tar.gz → 0.2.2__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 (136) hide show
  1. {trailmark-0.2.1 → trailmark-0.2.2}/PKG-INFO +5 -1
  2. {trailmark-0.2.1 → trailmark-0.2.2}/README.md +4 -0
  3. {trailmark-0.2.1 → trailmark-0.2.2}/pyproject.toml +1 -1
  4. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/__init__.py +1 -1
  5. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/analysis/entrypoints.py +12 -4
  6. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/cli.py +18 -1
  7. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/_common.py +45 -4
  8. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_cli_parser.py +55 -1
  9. trailmark-0.2.2/tests/test_common_parser.py +154 -0
  10. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_entrypoints.py +43 -4
  11. {trailmark-0.2.1 → trailmark-0.2.2}/uv.lock +2 -2
  12. {trailmark-0.2.1 → trailmark-0.2.2}/.github/dependabot.yml +0 -0
  13. {trailmark-0.2.1 → trailmark-0.2.2}/.github/workflows/ci.yml +0 -0
  14. {trailmark-0.2.1 → trailmark-0.2.2}/.github/workflows/mutation.yml +0 -0
  15. {trailmark-0.2.1 → trailmark-0.2.2}/.github/workflows/publish.yml +0 -0
  16. {trailmark-0.2.1 → trailmark-0.2.2}/.github/workflows/semgrep.yml +0 -0
  17. {trailmark-0.2.1 → trailmark-0.2.2}/.github/workflows/zizmor.yml +0 -0
  18. {trailmark-0.2.1 → trailmark-0.2.2}/.gitignore +0 -0
  19. {trailmark-0.2.1 → trailmark-0.2.2}/AGENTS.md +0 -0
  20. {trailmark-0.2.1 → trailmark-0.2.2}/CONTRIBUTING.md +0 -0
  21. {trailmark-0.2.1 → trailmark-0.2.2}/LICENSE +0 -0
  22. {trailmark-0.2.1 → trailmark-0.2.2}/NOTICE +0 -0
  23. {trailmark-0.2.1 → trailmark-0.2.2}/docs/entrypoint-patterns.md +0 -0
  24. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/analysis/__init__.py +0 -0
  25. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/analysis/augment.py +0 -0
  26. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/analysis/diff.py +0 -0
  27. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/analysis/preanalysis.py +0 -0
  28. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/diagram.py +0 -0
  29. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/models/__init__.py +0 -0
  30. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/models/annotations.py +0 -0
  31. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/models/edges.py +0 -0
  32. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/models/graph.py +0 -0
  33. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/models/nodes.py +0 -0
  34. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/__init__.py +0 -0
  35. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/base.py +0 -0
  36. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/c/__init__.py +0 -0
  37. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/c/parser.py +0 -0
  38. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/cairo/__init__.py +0 -0
  39. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/cairo/parser.py +0 -0
  40. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/circom/__init__.py +0 -0
  41. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/circom/parser.py +0 -0
  42. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/cpp/__init__.py +0 -0
  43. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/cpp/parser.py +0 -0
  44. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/csharp/__init__.py +0 -0
  45. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/csharp/parser.py +0 -0
  46. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/dart/__init__.py +0 -0
  47. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/dart/parser.py +0 -0
  48. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/erlang/__init__.py +0 -0
  49. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/erlang/parser.py +0 -0
  50. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/go/__init__.py +0 -0
  51. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/go/parser.py +0 -0
  52. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/haskell/__init__.py +0 -0
  53. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/haskell/parser.py +0 -0
  54. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/java/__init__.py +0 -0
  55. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/java/parser.py +0 -0
  56. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/javascript/__init__.py +0 -0
  57. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/javascript/parser.py +0 -0
  58. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/kotlin/__init__.py +0 -0
  59. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/kotlin/parser.py +0 -0
  60. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/masm/__init__.py +0 -0
  61. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/masm/parser.py +0 -0
  62. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/objc/__init__.py +0 -0
  63. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/objc/parser.py +0 -0
  64. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/php/__init__.py +0 -0
  65. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/php/parser.py +0 -0
  66. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/python/__init__.py +0 -0
  67. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/python/parser.py +0 -0
  68. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/ruby/__init__.py +0 -0
  69. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/ruby/parser.py +0 -0
  70. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/rust/__init__.py +0 -0
  71. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/rust/parser.py +0 -0
  72. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/solidity/__init__.py +0 -0
  73. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/solidity/parser.py +0 -0
  74. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/swift/__init__.py +0 -0
  75. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/swift/parser.py +0 -0
  76. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/typescript/__init__.py +0 -0
  77. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/parsers/typescript/parser.py +0 -0
  78. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/py.typed +0 -0
  79. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/query/__init__.py +0 -0
  80. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/query/api.py +0 -0
  81. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/storage/__init__.py +0 -0
  82. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/storage/graph_store.py +0 -0
  83. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/__init__.py +0 -0
  84. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/circom/LICENSE +0 -0
  85. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/circom/__init__.py +0 -0
  86. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/circom/binding.c +0 -0
  87. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/circom/grammar.js +0 -0
  88. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/circom/src/parser.c +0 -0
  89. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/circom/src/tree_sitter/parser.h +0 -0
  90. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/masm/LICENSE +0 -0
  91. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/masm/__init__.py +0 -0
  92. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/masm/binding.c +0 -0
  93. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/masm/grammar.js +0 -0
  94. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/masm/src/parser.c +0 -0
  95. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/masm/src/tree_sitter/alloc.h +0 -0
  96. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/masm/src/tree_sitter/array.h +0 -0
  97. {trailmark-0.2.1 → trailmark-0.2.2}/src/trailmark/tree_sitter_custom/masm/src/tree_sitter/parser.h +0 -0
  98. {trailmark-0.2.1 → trailmark-0.2.2}/tests/__init__.py +0 -0
  99. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_augment.py +0 -0
  100. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_c_parser.py +0 -0
  101. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_cairo_parser.py +0 -0
  102. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_circom_grammar.py +0 -0
  103. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_circom_parser.py +0 -0
  104. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_cli.py +0 -0
  105. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_cpp_parser.py +0 -0
  106. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_csharp_parser.py +0 -0
  107. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_dart_parser.py +0 -0
  108. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_detector_tags.py +0 -0
  109. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_diagram_script.py +0 -0
  110. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_diff.py +0 -0
  111. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_erlang_parser.py +0 -0
  112. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_go_parser.py +0 -0
  113. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_haskell_parser.py +0 -0
  114. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_hidden_apis.py +0 -0
  115. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_java_parser.py +0 -0
  116. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_javascript_parser.py +0 -0
  117. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_kotlin_parser.py +0 -0
  118. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_masm_parser.py +0 -0
  119. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_models.py +0 -0
  120. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_objc_parser.py +0 -0
  121. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_parser_edge_cases.py +0 -0
  122. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_php_parser.py +0 -0
  123. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_polyglot.py +0 -0
  124. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_preanalysis.py +0 -0
  125. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_property_based.py +0 -0
  126. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_python_parser.py +0 -0
  127. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_query.py +0 -0
  128. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_query_edge_cases.py +0 -0
  129. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_readme.py +0 -0
  130. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_ruby_parser.py +0 -0
  131. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_rust_parser.py +0 -0
  132. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_solidity_parser.py +0 -0
  133. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_storage.py +0 -0
  134. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_storage_edge_cases.py +0 -0
  135. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_swift_parser.py +0 -0
  136. {trailmark-0.2.1 → trailmark-0.2.2}/tests/test_typescript_parser.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trailmark
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -260,6 +260,10 @@ Requires Python ≥ 3.12.
260
260
  ## Usage
261
261
 
262
262
  ```bash
263
+ # Report the installed version
264
+ trailmark --version # or: trailmark -V
265
+ trailmark version # subcommand form
266
+
263
267
  # Full JSON graph (Python, the default)
264
268
  trailmark analyze path/to/project
265
269
 
@@ -243,6 +243,10 @@ Requires Python ≥ 3.12.
243
243
  ## Usage
244
244
 
245
245
  ```bash
246
+ # Report the installed version
247
+ trailmark --version # or: trailmark -V
248
+ trailmark version # subcommand form
249
+
246
250
  # Full JSON graph (Python, the default)
247
251
  trailmark analyze path/to/project
248
252
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "trailmark"
7
- version = "0.2.1"
7
+ version = "0.2.2"
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"
@@ -1,3 +1,3 @@
1
1
  """Trailmark: Parse source code into queryable graphs for security analysis."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
@@ -1106,9 +1106,9 @@ def _detect_pyproject_scripts(
1106
1106
  ) -> dict[str, EntrypointTag]:
1107
1107
  """Read ``[project.scripts]`` from pyproject.toml and tag each target.
1108
1108
 
1109
- Entries take the form ``name = "module.path:function"``. We locate the
1110
- matching node by (file path suffix, function name) because Trailmark's
1111
- node IDs use file basenames rather than full module paths.
1109
+ Entries take the form ``name = "module.path:function"``. We first try the
1110
+ matching node ID directly, then fall back to an unambiguous file path suffix
1111
+ match for parses rooted below the import root.
1112
1112
  """
1113
1113
  pyproject = repo_root / "pyproject.toml"
1114
1114
  if not pyproject.exists():
@@ -1149,12 +1149,20 @@ def _resolve_script_target(
1149
1149
  func_name: str,
1150
1150
  ) -> str | None:
1151
1151
  """Find the node id matching a ``module.path:function`` script target."""
1152
+ exact_id = f"{module_path}:{func_name}"
1153
+ unit = graph.nodes.get(exact_id)
1154
+ if unit is not None and unit.name == func_name:
1155
+ return exact_id
1156
+
1152
1157
  suffix = module_path.replace(".", "/") + ".py"
1158
+ matches: list[str] = []
1153
1159
  for node_id, unit in graph.nodes.items():
1154
1160
  if unit.name != func_name:
1155
1161
  continue
1156
1162
  if unit.location.file_path.endswith(suffix):
1157
- return node_id
1163
+ matches.append(node_id)
1164
+ if len(matches) == 1:
1165
+ return matches[0]
1158
1166
  return None
1159
1167
 
1160
1168
 
@@ -8,17 +8,31 @@ import sys
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
+ from trailmark import __version__
11
12
  from trailmark.query.api import QueryEngine
12
13
 
14
+ _VERSION_STRING = f"trailmark {__version__}"
15
+
13
16
 
14
17
  def build_parser() -> argparse.ArgumentParser:
15
18
  """Construct the Trailmark CLI's argparse tree."""
16
19
  parser = argparse.ArgumentParser(
17
20
  prog="trailmark",
18
- description="Parse source code into queryable graphs",
21
+ description=f"Parse source code into queryable graphs (v{__version__})",
22
+ )
23
+ parser.add_argument(
24
+ "--version",
25
+ "-V",
26
+ action="version",
27
+ version=_VERSION_STRING,
19
28
  )
20
29
  subparsers = parser.add_subparsers(dest="command")
21
30
 
31
+ subparsers.add_parser(
32
+ "version",
33
+ help="Print the Trailmark version and exit",
34
+ )
35
+
22
36
  analyze = subparsers.add_parser(
23
37
  "analyze",
24
38
  help="Analyze a directory and output the code graph",
@@ -133,6 +147,9 @@ def main() -> None:
133
147
  parser.print_help()
134
148
  sys.exit(1)
135
149
 
150
+ if args.command == "version":
151
+ print(_VERSION_STRING)
152
+ return
136
153
  if args.command == "analyze":
137
154
  _run_analyze(args)
138
155
  elif args.command == "augment":
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  from collections.abc import Callable
7
+ from contextvars import ContextVar
7
8
  from pathlib import Path
8
9
  from typing import TYPE_CHECKING
9
10
 
@@ -39,13 +40,49 @@ def make_location(node: Node, file_path: str) -> SourceLocation:
39
40
  )
40
41
 
41
42
 
43
+ _DIRECTORY_PARSE_ROOT: ContextVar[Path | None] = ContextVar(
44
+ "_DIRECTORY_PARSE_ROOT",
45
+ default=None,
46
+ )
47
+
48
+
42
49
  def module_id_from_path(file_path: str) -> str:
43
- """Derive a module ID from a file path."""
50
+ """Derive a module ID from a file path.
51
+
52
+ Single-file parsing preserves the historical filename-stem ID. Directory
53
+ parsing uses a root-relative dotted path so same-named files in different
54
+ directories do not overwrite each other when their graphs are merged.
55
+ """
44
56
  p = Path(file_path)
57
+ root = _DIRECTORY_PARSE_ROOT.get()
58
+ if root is not None:
59
+ try:
60
+ rel_path = _absolute_lexical_path(p).relative_to(root)
61
+ except ValueError:
62
+ pass
63
+ else:
64
+ if rel_path.stem == "__init__":
65
+ module_path = rel_path.parent
66
+ if module_path == Path("."):
67
+ return _escape_module_path_part(root.name)
68
+ else:
69
+ module_path = rel_path.with_suffix("")
70
+ return ".".join(_escape_module_path_part(part) for part in module_path.parts)
71
+
45
72
  stem = p.stem if p.stem != "__init__" else p.parent.name
46
73
  return stem
47
74
 
48
75
 
76
+ def _absolute_lexical_path(path: Path) -> Path:
77
+ """Return an absolute path without resolving symlink targets."""
78
+ return Path(os.path.abspath(path))
79
+
80
+
81
+ def _escape_module_path_part(part: str) -> str:
82
+ """Escape separators used in dotted module IDs."""
83
+ return part.replace("\\", "\\\\").replace(".", "\\.")
84
+
85
+
49
86
  _EXCLUDED_DIRS = frozenset(
50
87
  {
51
88
  ".git",
@@ -98,9 +135,13 @@ def parse_directory(
98
135
  extensions: File extensions to include.
99
136
  """
100
137
  merged = CodeGraph(language=language, root_path=dir_path)
101
- for fpath in walk_source_files(dir_path, extensions):
102
- file_graph = parse_file_fn(fpath)
103
- merged.merge(file_graph)
138
+ token = _DIRECTORY_PARSE_ROOT.set(_absolute_lexical_path(Path(dir_path)))
139
+ try:
140
+ for fpath in walk_source_files(dir_path, extensions):
141
+ file_graph = parse_file_fn(fpath)
142
+ merged.merge(file_graph)
143
+ finally:
144
+ _DIRECTORY_PARSE_ROOT.reset(token)
104
145
  return merged
105
146
 
106
147
 
@@ -58,7 +58,48 @@ class TestTopLevelParser:
58
58
  def test_all_subcommands_registered(
59
59
  self, subparsers_map: dict[str, argparse.ArgumentParser]
60
60
  ) -> None:
61
- assert set(subparsers_map) == {"analyze", "augment", "entrypoints", "diff"}
61
+ assert set(subparsers_map) == {
62
+ "analyze",
63
+ "augment",
64
+ "entrypoints",
65
+ "diff",
66
+ "version",
67
+ }
68
+
69
+
70
+ class TestVersionFlag:
71
+ """Regression for issue #26 — the CLI must expose its version."""
72
+
73
+ def test_long_flag_prints_and_exits(
74
+ self, parser: argparse.ArgumentParser, capsys: pytest.CaptureFixture[str]
75
+ ) -> None:
76
+ import trailmark
77
+
78
+ with pytest.raises(SystemExit) as excinfo:
79
+ parser.parse_args(["--version"])
80
+ assert excinfo.value.code == 0
81
+ out = capsys.readouterr().out
82
+ assert out.strip() == f"trailmark {trailmark.__version__}"
83
+
84
+ def test_short_flag_prints_and_exits(
85
+ self, parser: argparse.ArgumentParser, capsys: pytest.CaptureFixture[str]
86
+ ) -> None:
87
+ import trailmark
88
+
89
+ with pytest.raises(SystemExit) as excinfo:
90
+ parser.parse_args(["-V"])
91
+ assert excinfo.value.code == 0
92
+ out = capsys.readouterr().out
93
+ assert out.strip() == f"trailmark {trailmark.__version__}"
94
+
95
+ def test_version_subcommand_registered(
96
+ self, subparsers_map: dict[str, argparse.ArgumentParser]
97
+ ) -> None:
98
+ assert "version" in subparsers_map
99
+
100
+ def test_version_subcommand_parses(self, parser: argparse.ArgumentParser) -> None:
101
+ args = parser.parse_args(["version"])
102
+ assert args.command == "version"
62
103
 
63
104
 
64
105
  class TestAnalyzeSubparser:
@@ -206,3 +247,16 @@ class TestParseBehavior:
206
247
  def test_augment_repeatable_sarif(self, parser: argparse.ArgumentParser) -> None:
207
248
  args = parser.parse_args(["augment", "p", "--sarif", "a.sarif", "--sarif", "b.sarif"])
208
249
  assert args.sarif == ["a.sarif", "b.sarif"]
250
+
251
+
252
+ class TestVersionSubcommandExecution:
253
+ def test_main_version_prints_version(
254
+ self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
255
+ ) -> None:
256
+ import trailmark
257
+ from trailmark.cli import main
258
+
259
+ monkeypatch.setattr("sys.argv", ["trailmark", "version"])
260
+ main()
261
+ out = capsys.readouterr().out
262
+ assert out.strip() == f"trailmark {trailmark.__version__}"
@@ -0,0 +1,154 @@
1
+ """Tests for parser utilities shared across language implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from trailmark.models.edges import CodeEdge, EdgeKind
10
+ from trailmark.models.graph import CodeGraph
11
+ from trailmark.models.nodes import CodeUnit, NodeKind, SourceLocation
12
+ from trailmark.parsers._common import module_id_from_path, parse_directory
13
+
14
+
15
+ def _fake_parse_file(file_path: str) -> CodeGraph:
16
+ """Build a tiny graph using the shared module ID helper."""
17
+ module_id = module_id_from_path(file_path)
18
+ function_id = f"{module_id}:helper"
19
+ location = SourceLocation(file_path=file_path, start_line=1, end_line=1)
20
+ return CodeGraph(
21
+ nodes={
22
+ module_id: CodeUnit(
23
+ id=module_id,
24
+ name=module_id,
25
+ kind=NodeKind.MODULE,
26
+ location=location,
27
+ ),
28
+ function_id: CodeUnit(
29
+ id=function_id,
30
+ name="helper",
31
+ kind=NodeKind.FUNCTION,
32
+ location=location,
33
+ ),
34
+ },
35
+ edges=[
36
+ CodeEdge(
37
+ source_id=module_id,
38
+ target_id=function_id,
39
+ kind=EdgeKind.CONTAINS,
40
+ )
41
+ ],
42
+ language="test",
43
+ root_path=file_path,
44
+ )
45
+
46
+
47
+ def test_module_id_from_path_preserves_single_file_stem_behavior(tmp_path: Path) -> None:
48
+ file_path = tmp_path / "src" / "compat.py"
49
+ file_path.parent.mkdir()
50
+ file_path.write_text("")
51
+
52
+ assert module_id_from_path(str(file_path)) == "compat"
53
+
54
+
55
+ def test_parse_directory_uses_root_relative_module_ids(tmp_path: Path) -> None:
56
+ src = tmp_path / "src"
57
+ tests = tmp_path / "tests"
58
+ src.mkdir()
59
+ tests.mkdir()
60
+ (src / "compat.py").write_text("")
61
+ (tests / "compat.py").write_text("")
62
+
63
+ graph = parse_directory(
64
+ _fake_parse_file,
65
+ language="test",
66
+ dir_path=str(tmp_path),
67
+ extensions=(".py",),
68
+ )
69
+
70
+ assert set(graph.nodes) == {
71
+ "src.compat",
72
+ "src.compat:helper",
73
+ "tests.compat",
74
+ "tests.compat:helper",
75
+ }
76
+ assert {(edge.source_id, edge.target_id) for edge in graph.edges} == {
77
+ ("src.compat", "src.compat:helper"),
78
+ ("tests.compat", "tests.compat:helper"),
79
+ }
80
+
81
+
82
+ def test_parse_directory_escapes_dotted_path_components(tmp_path: Path) -> None:
83
+ dotted_dir = tmp_path / "a.b"
84
+ dotted_file_parent = tmp_path / "a"
85
+ dotted_dir.mkdir()
86
+ dotted_file_parent.mkdir()
87
+ (dotted_dir / "c.py").write_text("")
88
+ (dotted_file_parent / "b.c.py").write_text("")
89
+
90
+ graph = parse_directory(
91
+ _fake_parse_file,
92
+ language="test",
93
+ dir_path=str(tmp_path),
94
+ extensions=(".py",),
95
+ )
96
+
97
+ assert set(graph.nodes) == {
98
+ r"a\.b.c",
99
+ r"a\.b.c:helper",
100
+ r"a.b\.c",
101
+ r"a.b\.c:helper",
102
+ }
103
+
104
+
105
+ def test_parse_directory_uses_package_path_for_init_files(tmp_path: Path) -> None:
106
+ pkg = tmp_path / "pkg"
107
+ subpkg = pkg / "subpkg"
108
+ subpkg.mkdir(parents=True)
109
+ (pkg / "__init__.py").write_text("")
110
+ (subpkg / "__init__.py").write_text("")
111
+
112
+ graph = parse_directory(
113
+ _fake_parse_file,
114
+ language="test",
115
+ dir_path=str(tmp_path),
116
+ extensions=(".py",),
117
+ )
118
+
119
+ assert set(graph.nodes) == {
120
+ "pkg",
121
+ "pkg:helper",
122
+ "pkg.subpkg",
123
+ "pkg.subpkg:helper",
124
+ }
125
+
126
+
127
+ def test_parse_directory_uses_lexical_path_for_symlinked_files(tmp_path: Path) -> None:
128
+ root = tmp_path / "root"
129
+ outside = tmp_path / "outside"
130
+ src = root / "src"
131
+ tests = root / "tests"
132
+ outside.mkdir()
133
+ src.mkdir(parents=True)
134
+ tests.mkdir()
135
+ (outside / "compat.py").write_text("")
136
+ (tests / "compat.py").write_text("")
137
+ try:
138
+ (src / "compat.py").symlink_to(outside / "compat.py")
139
+ except OSError:
140
+ pytest.skip("symlink creation is not supported on this platform")
141
+
142
+ graph = parse_directory(
143
+ _fake_parse_file,
144
+ language="test",
145
+ dir_path=str(root),
146
+ extensions=(".py",),
147
+ )
148
+
149
+ assert set(graph.nodes) == {
150
+ "src.compat",
151
+ "src.compat:helper",
152
+ "tests.compat",
153
+ "tests.compat:helper",
154
+ }
@@ -46,8 +46,8 @@ class TestSelfAnalysis:
46
46
  engine = QueryEngine.from_directory(str(SRC), language="python")
47
47
  surface = engine.attack_surface()
48
48
  node_ids = {ep["node_id"] for ep in surface}
49
- assert "cli:main" in node_ids, (
50
- f"Expected cli:main (the pyproject.toml script target) in {node_ids}"
49
+ assert "trailmark.cli:main" in node_ids, (
50
+ f"Expected trailmark.cli:main (the pyproject.toml script target) in {node_ids}"
51
51
  )
52
52
 
53
53
 
@@ -106,6 +106,28 @@ class TestPyprojectScripts:
106
106
  ids = {ep["node_id"] for ep in engine.attack_surface()}
107
107
  assert "app:main" in ids
108
108
 
109
+ def test_pyproject_script_ambiguous_suffix_is_skipped(self, tmp_path: Path) -> None:
110
+ """Suffix fallback must not select an order-dependent script target."""
111
+ (tmp_path / "pyproject.toml").write_text(
112
+ "[project]\n"
113
+ 'name = "demo"\n'
114
+ 'version = "0.0.0"\n'
115
+ "[project.scripts]\n"
116
+ 'demo = "pkg.app:main"\n',
117
+ )
118
+ src_pkg = tmp_path / "src" / "pkg"
119
+ tests_pkg = tmp_path / "tests" / "pkg"
120
+ src_pkg.mkdir(parents=True)
121
+ tests_pkg.mkdir(parents=True)
122
+ (src_pkg / "app.py").write_text("def main():\n pass\n")
123
+ (tests_pkg / "app.py").write_text("def main():\n pass\n")
124
+
125
+ engine = QueryEngine.from_directory(str(tmp_path))
126
+ surface = {ep["node_id"]: ep for ep in engine.attack_surface()}
127
+
128
+ assert surface["src.pkg.app:main"]["trust_level"] == "trusted_internal"
129
+ assert surface["tests.pkg.app:main"]["trust_level"] == "trusted_internal"
130
+
109
131
  def test_malformed_pyproject_is_tolerated(self, tmp_path: Path) -> None:
110
132
  """A broken pyproject.toml must not crash detection."""
111
133
  (tmp_path / "pyproject.toml").write_text("this is not valid toml = [")
@@ -169,7 +191,24 @@ class TestOverrideFile:
169
191
  )
170
192
  engine = QueryEngine.from_directory(str(tmp_path))
171
193
  ids = {ep["node_id"] for ep in engine.attack_surface()}
172
- assert "app:serve" in ids
194
+ assert "pkg.app:serve" in ids
195
+
196
+ def test_override_ambiguous_module_reference_is_skipped(self, tmp_path: Path) -> None:
197
+ """Ambiguous module references should not tag the first suffix match."""
198
+ src_pkg = tmp_path / "src" / "pkg"
199
+ tests_pkg = tmp_path / "tests" / "pkg"
200
+ src_pkg.mkdir(parents=True)
201
+ tests_pkg.mkdir(parents=True)
202
+ (src_pkg / "app.py").write_text("def serve():\n pass\n")
203
+ (tests_pkg / "app.py").write_text("def serve():\n pass\n")
204
+ self._write_override(
205
+ tmp_path,
206
+ '[[entrypoint]]\nnode = "pkg.app:serve"\nkind = "api"\n',
207
+ )
208
+
209
+ engine = QueryEngine.from_directory(str(tmp_path))
210
+
211
+ assert engine.attack_surface() == []
173
212
 
174
213
  def test_override_unknown_node_is_skipped(self, tmp_path: Path) -> None:
175
214
  (tmp_path / "app.py").write_text("def real():\n pass\n")
@@ -886,7 +925,7 @@ class TestBulkOverrideRules:
886
925
  engine = QueryEngine.from_directory(str(tmp_path), language="python")
887
926
  ids = {ep["node_id"] for ep in engine.attack_surface()}
888
927
  # In public/ AND matches name_regex -> yes
889
- assert "web:handle_login" in ids
928
+ assert "public.web:handle_login" in ids
890
929
  # In public/ but fails name_regex -> no
891
930
  assert "util:helper" not in ids
892
931
  # Matches name_regex but not in public/ -> no
@@ -8,7 +8,7 @@ resolution-markers = [
8
8
  ]
9
9
 
10
10
  [options]
11
- exclude-newer = "2026-04-16T18:39:10.32177Z"
11
+ exclude-newer = "2026-04-17T00:53:06.94394Z"
12
12
  exclude-newer-span = "P1W"
13
13
 
14
14
  [[package]]
@@ -599,7 +599,7 @@ wheels = [
599
599
 
600
600
  [[package]]
601
601
  name = "trailmark"
602
- version = "0.2.1"
602
+ version = "0.2.2"
603
603
  source = { editable = "." }
604
604
  dependencies = [
605
605
  { name = "rustworkx" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes