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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
[![Tests](https://img.shields.io/badge/tests-80%20passing-brightgreen)](#testing)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)

<img src="assets/banner.gif" alt="Graphora: source files flow into a live knowledge graph that powers blast radius, diff review with risk memory, and MCP tools for agents. 22,160 repo tokens shrink to 1,237." width="900" />
<img src="https://raw.githubusercontent.com/Naseem77/Graphora/main/assets/banner.gif" alt="Graphora: source files flow into a live knowledge graph that powers blast radius, diff review with risk memory, and MCP tools for agents. 22,160 repo tokens shrink to 1,237." width="900" />

`pip install` it as a library · run it as a CLI · plug it into any AI agent as an MCP server · or install the skill into 22 agents with one command

Expand Down
2 changes: 1 addition & 1 deletion graphora/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import importlib

__version__ = "0.2.0"
__version__ = "0.2.1"

_LAZY = {
"parse_code_file": "graphora.parser",
Expand Down
19 changes: 16 additions & 3 deletions graphora/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,11 @@ def _parse_with_tree_sitter(path: str, content: str, language: str) -> ParsedFil
if symbol:
symbols.append(symbol)
if symbol.kind == "Function":
function_ranges.append((symbol.name, node.start_point.row + 1, node.end_point.row + 1))
function_ranges.append((symbol.name, _point_row(node.start_point) + 1, _point_row(node.end_point) + 1))

call_name = _tree_sitter_call(language, node, content_bytes)
if call_name:
line = node.start_point.row + 1
line = _point_row(node.start_point) + 1
caller = _caller_for_line(function_ranges, line)
if caller and caller != call_name:
calls.append(ParsedCall(caller=caller, callee=call_name, line=line, confidence=INFERRED))
Expand Down Expand Up @@ -217,6 +217,19 @@ def _tree_sitter_parser(language: str) -> Any | None:
return None


def _point_row(point: Any) -> int:
"""Read a tree-sitter ``Point``'s row via tuple/index access.

tree-sitter 0.26.0 has a known upstream bug (already fixed, not yet
released) where the named ``Point.row``/``Point.column`` attributes
return unstable values for source coordinates beyond the small-integer
range, while tuple/index access (``point[0]``, ``point[1]``) remains
stable across affected and unaffected versions alike. Always read the
row this way rather than via ``point.row``.
"""
return point[0]


def _walk_nodes(node: Any) -> list[Any]:
nodes = [node]
for child in node.children:
Expand Down Expand Up @@ -299,7 +312,7 @@ def _tree_sitter_symbol(language: str, node: Any, content_bytes: bytes, content:

if not kind or name_node is None:
return None
line = node.start_point.row + 1
line = _point_row(node.start_point) + 1
signature = content.splitlines()[line - 1].strip()
name = _node_text(name_node, content_bytes).split("::")[-1]
if not re.match(r"^[A-Za-z_][\w$]*$", name):
Expand Down
11 changes: 9 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "graphora-kg"
version = "0.2.0"
version = "0.2.1"
description = "Deterministic code knowledge graph: index with tree-sitter, query blast radius, mine risk memory from git, review diffs grounded in structure. No LLM required to build."
readme = "README.md"
requires-python = ">=3.10"
Expand All @@ -24,7 +24,14 @@ classifiers = [
]
dependencies = [
"falkordb>=1.0",
"tree-sitter>=0.23",
# tree-sitter 0.26.0 has a known upstream bug (already fixed upstream, not
# yet released as of this writing): Point.row/Point.column can return
# unstable values for source coordinates beyond the small-integer range.
# Graphora reads points via safe tuple/index access (see graphora/parser.py
# _point_row) so it is not affected, but 0.26.0 is excluded here as a
# defense-in-depth measure for any other code path. Future corrected
# releases (>0.26.0) remain installable.
"tree-sitter>=0.23,!=0.26.0",
"tree-sitter-python>=0.23",
"tree-sitter-javascript>=0.23",
"tree-sitter-typescript>=0.23",
Expand Down
103 changes: 103 additions & 0 deletions tests/core/test_core_packaging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Package-metadata assertions: the tree-sitter dependency bound and version.

tree-sitter 0.26.0 is excluded (see `pyproject.toml` and
`graphora/parser.py::_point_row`) because of an upstream Point-coordinate
bug. These tests assert the exclusion is present in the installed package's
metadata -- so if the bound is accidentally dropped or widened, a normal
test failure catches it -- while leaving room for any future corrected
release above 0.26.0.

This file also asserts the project's active version surfaces
(`pyproject.toml`'s `[project].version`, `graphora.__version__`, and the
installed package metadata) agree, so a partial version bump is caught,
and that README.md's banner image uses an absolute URL (not a
repo-relative path, which PyPI cannot resolve) in the packaged long
description.
"""

import importlib.metadata as metadata
import re
from pathlib import Path

from packaging.requirements import Requirement
from packaging.specifiers import SpecifierSet

_PYPROJECT = Path(__file__).parent.parent.parent / "pyproject.toml"


def _pyproject_version() -> str:
text = _PYPROJECT.read_text(encoding="utf-8")
match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text)
assert match, "project version not found in pyproject.toml"
return match.group(1)


def _tree_sitter_requirement() -> Requirement:
requires = metadata.requires("graphora-kg") or []
for raw in requires:
req = Requirement(raw)
if req.name == "tree-sitter":
return req
raise AssertionError("graphora-kg metadata does not declare a tree-sitter requirement")


def test_tree_sitter_026_is_excluded():
req = _tree_sitter_requirement()
specifier = req.specifier
assert "0.26.0" not in specifier, "tree-sitter 0.26.0 must stay excluded (unstable Point coordinates)"
assert not specifier.contains("0.26.0", prereleases=True)


def test_tree_sitter_lower_bound_and_future_releases_allowed():
req = _tree_sitter_requirement()
specifier: SpecifierSet = req.specifier
# The historical floor stays installable...
assert specifier.contains("0.23.0", prereleases=True)
assert specifier.contains("0.25.2", prereleases=True)
# ...and a hypothetical corrected release after 0.26.0 stays supportable.
assert specifier.contains("0.26.1", prereleases=True)
assert specifier.contains("0.27.0", prereleases=True)


def test_pyproject_declares_matching_exclusion():
# Cross-check the source declaration too, so the test doesn't only pass
# against a stale installed .dist-info from a previous `pip install -e`.
text = _PYPROJECT.read_text(encoding="utf-8")
# Matches the dependency entry (e.g. "tree-sitter>=0.23,!=0.26.0") while
# skipping the bare "tree-sitter" keyword and "tree-sitter-<lang>" extras.
match = re.search(r'"tree-sitter(>=[^"]*)"', text)
assert match, "tree-sitter dependency line not found in pyproject.toml"
assert "!=0.26.0" in match.group(1)


def test_active_version_surfaces_agree():
import graphora

pyproject_version = _pyproject_version()
installed_version = metadata.version("graphora-kg")

assert graphora.__version__ == pyproject_version, (
f"graphora.__version__ ({graphora.__version__}) must match "
f"pyproject.toml's [project].version ({pyproject_version})"
)
assert installed_version == pyproject_version, (
f"installed graphora-kg metadata version ({installed_version}) must match "
f"pyproject.toml's [project].version ({pyproject_version}) -- reinstall "
"(`pip install -e .`) after bumping the version"
)


def test_readme_banner_uses_absolute_url_in_packaged_long_description():
# README.md's banner previously used a repo-relative <img src="assets/banner.gif">,
# which PyPI cannot reliably resolve (PyPI renders the long description outside
# the repository, with no relative-path base). It must be an absolute HTTPS URL
# to the raw asset so the image renders on the PyPI project page.
meta = metadata.metadata("graphora-kg")
long_description = meta.get("Description") or meta.get_payload() or ""
assert long_description, "graphora-kg metadata has no long description"

absolute_url = "https://raw.githubusercontent.com/Naseem77/Graphora/main/assets/banner.gif"
assert absolute_url in long_description, "packaged long description must reference the absolute banner URL"
assert 'src="assets/banner.gif"' not in long_description, (
"packaged long description must not contain the repo-relative banner path"
)
81 changes: 81 additions & 0 deletions tests/core/test_core_parser_large_coordinates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Regression test: tree-sitter Point coordinates beyond the small-integer range.

tree-sitter 0.26.0 has a known upstream bug (already fixed, not yet released
as of writing) where reading a node's ``Point`` via the named ``.row``/
``.column`` attributes is unstable for source coordinates beyond the small
integer range (observed on real-world files such as axios's `index.d.ts`,
first triggered near line 386). Reading the same coordinates via tuple/index
access (``point[0]``, ``point[1]``) remains stable. Graphora reads points via
``graphora.parser._point_row``, which uses index access exclusively.

This fixture (`tests/fixtures/large_coordinates.ts`) is a small, locally
authored TypeScript file with two functions defined past line 256 (rows
261 and 265, 0-based) so the relevant coordinates fall outside the affected
small-integer range. The parse runs in a fresh subprocess so that any
dependency regression -- in tree-sitter itself, or in a future edit to
`graphora/parser.py` that reintroduces named attribute access -- surfaces as
an ordinary, isolated test failure rather than corrupting this test process.
"""

import json
import subprocess
import sys
from pathlib import Path

FIXTURE = Path(__file__).parent.parent / "fixtures" / "large_coordinates.ts"

# Expected (1-based) source lines, computed independently of the parser by
# construction of the fixture file itself.
EXPECTED_ALPHA_LINE = 262
EXPECTED_BETA_LINE = 266
EXPECTED_CALL_LINE = 267

_SUBPROCESS_SCRIPT = """
import json
import sys

from graphora.parser import parse_code_file

path = sys.argv[1]
with open(path, "r", encoding="utf-8") as f:
content = f.read()

parsed = parse_code_file("fixtures/large_coordinates.ts", content)
functions = {s.name: s.line for s in parsed.symbols if s.kind == "Function"}
calls = [
{"caller": c.caller, "callee": c.callee, "line": c.line, "confidence": c.confidence}
for c in parsed.calls
]
print(json.dumps({
"parser_used": parsed.parser_used,
"functions": functions,
"calls": calls,
}))
"""


def test_large_row_coordinates_use_stable_point_access():
assert FIXTURE.exists(), f"missing fixture: {FIXTURE}"

result = subprocess.run(
[sys.executable, "-c", _SUBPROCESS_SCRIPT, str(FIXTURE)],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)

assert payload["parser_used"] == "tree-sitter"

functions = payload["functions"]
assert functions.get("computeAlpha") == EXPECTED_ALPHA_LINE
assert functions.get("computeBeta") == EXPECTED_BETA_LINE

calls = payload["calls"]
assert any(
c["caller"] == "computeBeta"
and c["callee"] == "computeAlpha"
and c["line"] == EXPECTED_CALL_LINE
for c in calls
), calls
Loading
Loading