Skip to content

Commit be71d97

Browse files
committed
fix docs drift and javascript module detection
1 parent 9113ef5 commit be71d97

7 files changed

Lines changed: 124 additions & 19 deletions

File tree

AGENTS.md

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ Development context for AI agents working on this codebase.
55
## Project Overview
66

77
Trailmark parses source code into directed graphs of functions, classes,
8-
calls, and semantic metadata for security analysis. It supports 16
9-
languages via tree-sitter (plus a bundled Circom grammar) and uses
10-
rustworkx for graph traversal.
8+
calls, and semantic metadata for security analysis. It supports more than
9+
twenty language frontends via tree-sitter-language-pack plus bundled
10+
custom grammars, and uses rustworkx for graph traversal. Treat
11+
`README.md` as the authoritative public feature list.
1112

1213
## Architecture
1314

@@ -19,17 +20,22 @@ CodeGraph (data) -> GraphStore (indexed storage) -> QueryEngine (facade)
1920
- **GraphStore** wraps CodeGraph in a rustworkx PyDiGraph. Validates
2021
node existence. Returns model objects.
2122
- **QueryEngine** resolves names to node IDs, delegates to GraphStore,
22-
returns plain dicts for JSON serialization.
23+
and returns JSON-serializable data structures for user-facing APIs.
2324

2425
## Key Conventions
2526

26-
- All public methods return `False` or `[]` for missing nodes. Never raise.
27-
- QueryEngine returns dicts; GraphStore returns model objects.
27+
- Query/read methods should return `False`, `[]`, or empty sets for
28+
missing nodes where practical. Factory methods and invalid-input paths
29+
may raise `ValueError` or exit via the CLI.
30+
- QueryEngine returns JSON-serializable data structures; GraphStore
31+
returns model objects and node IDs.
2832
- Node IDs follow `module:function`, `module:Class`, `module:Class.method`.
2933
- Edge confidence: `certain`, `inferred`, `uncertain`.
3034
- Annotation sources: `"llm"`, `"docstring"`, `"manual"`.
3135
- Frozen dataclasses for immutable data. `CodeGraph` is mutable.
3236
- No relative (`..`) imports. Use `from trailmark.*`.
37+
- Update `README.md` and `tests/test_readme.py` when documented behavior
38+
changes.
3339

3440
## File Layout
3541

@@ -46,6 +52,7 @@ src/trailmark/
4652
python/ # One subpackage per language
4753
javascript/
4854
...
55+
analysis/ # Higher-level passes: entrypoints, diff, augment, preanalysis
4956
storage/
5057
graph_store.py # GraphStore: rustworkx-backed indexed storage
5158
query/
@@ -57,10 +64,11 @@ tests/ # pytest test suite
5764
## Running Checks
5865

5966
```bash
60-
uv run ruff check --fix src/ tests/
61-
uv run ruff format src/ tests/
62-
uv run ty check
63-
pytest -q
67+
uv sync --all-groups
68+
uv run ruff check src/ tests/
69+
uv run ruff format --check src/ tests/
70+
uv tool install ty && ty check
71+
uv run pytest -q tests/
6472
```
6573

6674
## Mutation Testing
@@ -85,6 +93,10 @@ This is not needed on Linux/CI (Ubuntu).
8593
- Follow the three-layer pattern: add to CodeGraph first, then GraphStore
8694
(with validation), then QueryEngine (with name resolution and dict
8795
conversion).
88-
- Add tests at each layer: `test_models.py`, `test_storage.py`,
89-
`test_query.py`.
90-
- Update `README.md` if adding user-facing API.
96+
- Analysis passes live under `trailmark.analysis` and should usually
97+
persist results as annotations or named subgraphs rather than
98+
bypassing the graph model.
99+
- Add tests at each relevant layer: `test_models.py`, `test_storage.py`,
100+
`test_query.py`, plus focused parser/analysis/doc regression tests.
101+
- Update `README.md` and the matching regression tests if adding or
102+
changing user-facing API.

README.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ A language-specific parser walks the directory, parses each file into a tree-sit
4343
| Language | Extensions | Key constructs |
4444
| --- | --- | --- |
4545
| Python | `.py` | functions, classes, methods |
46-
| JavaScript | `.js`, `.jsx` | functions, classes, arrow functions |
46+
| JavaScript | `.js`, `.jsx`, `.mjs`, `.cjs` | functions, classes, arrow functions |
4747
| TypeScript | `.ts`, `.tsx` | functions, classes, interfaces, enums |
4848
| PHP | `.php` | functions, classes, interfaces, traits |
4949
| Ruby | `.rb` | methods, classes, modules |
@@ -111,11 +111,17 @@ The `QueryEngine` provides a high-level API over the indexed graph:
111111
| `attack_surface()` | Entrypoints tagged with trust level and asset value |
112112
| `complexity_hotspots(n)` | Functions with cyclomatic complexity ≥ n |
113113
| `functions_that_raise(exc)` | Functions whose parser-detected exception list includes `exc` |
114-
| `annotate(name, kind, desc, source)` | Add a semantic annotation to a node |
114+
| `annotate(name, kind, description, source)` | Add a semantic annotation to a node |
115115
| `annotations_of(name, kind=None)` | Get annotations for a node, optionally filtered by kind |
116116
| `nodes_with_annotation(kind)` | Every node tagged with the given annotation kind |
117117
| `clear_annotations(name, kind=None)` | Remove annotations from a node |
118118
| `diff_against(other)` | Structural diff of this engine's graph vs. another |
119+
| `preanalysis()` | Run the built-in pre-analysis passes and store annotations/subgraphs |
120+
| `augment_sarif(path)` | Merge SARIF findings into the graph |
121+
| `augment_weaudit(path)` | Merge weAudit findings into the graph |
122+
| `findings(kind=None)` | Return nodes carrying finding-style annotations |
123+
| `subgraph(name)` | Return the nodes in a named subgraph |
124+
| `subgraph_names()` | List every named subgraph currently on the graph |
119125
| `summary()` | Node counts, edge counts, dependencies |
120126
| `to_json()` | Full graph export |
121127

@@ -220,8 +226,16 @@ graph TD
220226

221227
## Installation
222228

229+
The examples below track the current development branch. For the latest
230+
published package, install from PyPI. For the exact feature set described
231+
here, install from a checkout and run commands via `uv run`.
232+
223233
```bash
234+
# Latest published release
224235
uv pip install trailmark
236+
237+
# Current checkout / development branch
238+
uv sync --all-groups
225239
```
226240

227241
Requires Python ≥ 3.12.
@@ -394,6 +408,15 @@ before = QueryEngine.from_directory("before/")
394408
diff = engine.diff_against(before)
395409
# diff contains: summary_delta, nodes {added/removed/modified},
396410
# edges {added/removed}, entrypoints {added/removed/modified}
411+
412+
# Run the built-in audit-oriented preanalysis passes
413+
engine.preanalysis()
414+
engine.findings()
415+
engine.subgraph_names()
416+
417+
# Programmatic augmentation hooks for external tooling
418+
engine.augment_sarif("results.sarif")
419+
engine.augment_weaudit("findings.json")
397420
```
398421

399422
## Development
@@ -410,7 +433,7 @@ uv run ruff format
410433
uv tool install ty && ty check
411434

412435
# Tests
413-
uv run pytest -q
436+
uv run pytest -q tests/
414437

415438
# Mutation testing (on macOS, set this env var to avoid rustworkx fork segfaults)
416439
OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES uv run mutmut run

src/trailmark/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Trailmark: Parse source code into queryable graphs for security analysis."""
22

3-
__version__ = "0.1.0"
3+
__version__ = "0.1.3"

src/trailmark/parsers/javascript/parser.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646

4747
_FUNCTION_EXPR_TYPES = frozenset({"arrow_function", "function", "function_expression"})
4848

49-
_EXTENSIONS = (".js", ".jsx")
49+
_EXTENSIONS = (".js", ".jsx", ".mjs", ".cjs")
5050

5151

5252
class JavaScriptParser:
@@ -69,7 +69,7 @@ def parse_file(self, file_path: str) -> CodeGraph:
6969
return graph
7070

7171
def parse_directory(self, dir_path: str) -> CodeGraph:
72-
"""Parse all .js/.jsx files under dir_path."""
72+
"""Parse all supported JavaScript files under ``dir_path``."""
7373
return parse_directory(
7474
self.parse_file,
7575
"javascript",

tests/test_javascript_parser.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import os
66
import tempfile
7+
from pathlib import Path
78

89
from trailmark.models.edges import EdgeConfidence, EdgeKind
910
from trailmark.models.graph import CodeGraph
@@ -258,6 +259,18 @@ def test_export_class_extracted(self) -> None:
258259
names = {n.name for n in graph.nodes.values()}
259260
assert "ExportedWidget" in names
260261

262+
263+
class TestJavaScriptParserExtensions:
264+
def test_parse_directory_includes_mjs_and_cjs(self, tmp_path: Path) -> None:
265+
(tmp_path / "alpha.mjs").write_text("export function alpha() { return 1; }\n")
266+
(tmp_path / "beta.cjs").write_text("function beta() { return 2; }\n")
267+
268+
graph = JavaScriptParser().parse_directory(str(tmp_path))
269+
270+
names = {n.name for n in graph.nodes.values()}
271+
assert "alpha" in names
272+
assert "beta" in names
273+
261274
def test_rest_parameter(self) -> None:
262275
graph = _parse_extra()
263276
fn = next(n for n in graph.nodes.values() if n.name == "withRest")

tests/test_polyglot.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ def test_skips_vendor_directories(self, tmp_path: Path) -> None:
4646
def test_missing_path_returns_empty(self, tmp_path: Path) -> None:
4747
assert detect_languages(str(tmp_path / "does-not-exist")) == []
4848

49+
def test_detects_modern_javascript_module_extensions(self, tmp_path: Path) -> None:
50+
(tmp_path / "route.mjs").write_text("export function handler() {}\n")
51+
(tmp_path / "worker.cjs").write_text("function worker() {}\n")
52+
assert detect_languages(str(tmp_path)) == ["javascript"]
53+
4954

5055
class TestFromDirectoryAuto:
5156
def test_auto_detects_and_merges(self, tmp_path: Path) -> None:
@@ -117,3 +122,9 @@ def test_single_language_preserved(self, tmp_path: Path) -> None:
117122
surface = engine.attack_surface()
118123
assert len(surface) == 1
119124
assert surface[0]["node_id"] == "a:main"
125+
126+
def test_auto_parses_mjs_files_into_nodes(self, tmp_path: Path) -> None:
127+
(tmp_path / "route.mjs").write_text("export function handler() { return 1; }\n")
128+
engine = QueryEngine.from_directory(str(tmp_path), language="auto")
129+
summary = engine.summary()
130+
assert summary["functions"] >= 1

tests/test_readme.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616

1717
import pytest
1818

19+
import trailmark
1920
from trailmark.cli import build_parser
21+
from trailmark.parsers.javascript.parser import _EXTENSIONS as JS_EXTENSIONS
2022
from trailmark.query.api import _PARSER_MAP, QueryEngine
2123

2224

@@ -72,6 +74,20 @@ def test_python_version_matches_pyproject(
7274
)
7375

7476

77+
class TestPackageMetadata:
78+
def test_dunder_version_matches_pyproject(
79+
self,
80+
pyproject_data: dict[str, object],
81+
) -> None:
82+
"""`trailmark.__version__` should match the packaged version."""
83+
project_raw = pyproject_data.get("project")
84+
assert isinstance(project_raw, dict), "pyproject.toml has no [project] table"
85+
project = cast("dict[str, object]", project_raw)
86+
version = project.get("version")
87+
assert isinstance(version, str), "project.version is missing or not a string"
88+
assert trailmark.__version__ == version
89+
90+
7591
class TestUsageSection:
7692
@pytest.fixture(scope="class")
7793
def cli_subcommands(self) -> set[str]:
@@ -108,6 +124,11 @@ def test_every_registered_parser_is_documented(self, readme_text: str) -> None:
108124
f"Parser `{key}` is registered but not documented in README"
109125
)
110126

127+
def test_javascript_extensions_match_parser(self, readme_text: str) -> None:
128+
"""README's JavaScript extension list should match the parser."""
129+
extensions = _extract_extensions_for_language(readme_text, "JavaScript")
130+
assert set(extensions) == set(JS_EXTENSIONS)
131+
111132

112133
class TestQueryEngineAPI:
113134
EXPECTED_METHODS = (
@@ -125,6 +146,12 @@ class TestQueryEngineAPI:
125146
"nodes_with_annotation",
126147
"clear_annotations",
127148
"diff_against",
149+
"preanalysis",
150+
"augment_sarif",
151+
"augment_weaudit",
152+
"findings",
153+
"subgraph",
154+
"subgraph_names",
128155
"summary",
129156
"to_json",
130157
)
@@ -257,3 +284,22 @@ def _extract_languages_from_readme(readme_text: str) -> list[str]:
257284
langs.append(first_cell)
258285
assert langs, "No language rows parsed from Supported Languages table"
259286
return langs
287+
288+
289+
def _extract_extensions_for_language(readme_text: str, language: str) -> list[str]:
290+
"""Return the parsed extension list from a README Supported Languages row."""
291+
section = re.search(
292+
r"### Supported Languages\s*\n(.*?)(?=\n###|\n## )",
293+
readme_text,
294+
re.DOTALL,
295+
)
296+
assert section, "Could not find '### Supported Languages' section in README"
297+
for line in section.group(1).splitlines():
298+
stripped = line.strip()
299+
if not stripped.startswith("|"):
300+
continue
301+
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
302+
if len(cells) < 2 or cells[0] != language:
303+
continue
304+
return [item.strip().strip("`") for item in cells[1].split(",") if item.strip()]
305+
raise AssertionError(f"Could not find `{language}` row in Supported Languages table")

0 commit comments

Comments
 (0)