diff --git a/README.md b/README.md index 8651656..22c012c 100644 --- a/README.md +++ b/README.md @@ -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) -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. +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. `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 diff --git a/graphora/__init__.py b/graphora/__init__.py index 069699b..913b970 100644 --- a/graphora/__init__.py +++ b/graphora/__init__.py @@ -9,7 +9,7 @@ import importlib -__version__ = "0.2.0" +__version__ = "0.2.1" _LAZY = { "parse_code_file": "graphora.parser", diff --git a/graphora/parser.py b/graphora/parser.py index 5fa040d..4c49324 100644 --- a/graphora/parser.py +++ b/graphora/parser.py @@ -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)) @@ -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: @@ -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): diff --git a/pyproject.toml b/pyproject.toml index 446ed60..54250d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -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", diff --git a/tests/core/test_core_packaging.py b/tests/core/test_core_packaging.py new file mode 100644 index 0000000..34b81a2 --- /dev/null +++ b/tests/core/test_core_packaging.py @@ -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-" 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 , + # 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" + ) diff --git a/tests/core/test_core_parser_large_coordinates.py b/tests/core/test_core_parser_large_coordinates.py new file mode 100644 index 0000000..602c20b --- /dev/null +++ b/tests/core/test_core_parser_large_coordinates.py @@ -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 diff --git a/tests/fixtures/large_coordinates.ts b/tests/fixtures/large_coordinates.ts new file mode 100644 index 0000000..a95f2be --- /dev/null +++ b/tests/fixtures/large_coordinates.ts @@ -0,0 +1,269 @@ +export type Padding0 = number; +export type Padding1 = number; +export type Padding2 = number; +export type Padding3 = number; +export type Padding4 = number; +export type Padding5 = number; +export type Padding6 = number; +export type Padding7 = number; +export type Padding8 = number; +export type Padding9 = number; +export type Padding10 = number; +export type Padding11 = number; +export type Padding12 = number; +export type Padding13 = number; +export type Padding14 = number; +export type Padding15 = number; +export type Padding16 = number; +export type Padding17 = number; +export type Padding18 = number; +export type Padding19 = number; +export type Padding20 = number; +export type Padding21 = number; +export type Padding22 = number; +export type Padding23 = number; +export type Padding24 = number; +export type Padding25 = number; +export type Padding26 = number; +export type Padding27 = number; +export type Padding28 = number; +export type Padding29 = number; +export type Padding30 = number; +export type Padding31 = number; +export type Padding32 = number; +export type Padding33 = number; +export type Padding34 = number; +export type Padding35 = number; +export type Padding36 = number; +export type Padding37 = number; +export type Padding38 = number; +export type Padding39 = number; +export type Padding40 = number; +export type Padding41 = number; +export type Padding42 = number; +export type Padding43 = number; +export type Padding44 = number; +export type Padding45 = number; +export type Padding46 = number; +export type Padding47 = number; +export type Padding48 = number; +export type Padding49 = number; +export type Padding50 = number; +export type Padding51 = number; +export type Padding52 = number; +export type Padding53 = number; +export type Padding54 = number; +export type Padding55 = number; +export type Padding56 = number; +export type Padding57 = number; +export type Padding58 = number; +export type Padding59 = number; +export type Padding60 = number; +export type Padding61 = number; +export type Padding62 = number; +export type Padding63 = number; +export type Padding64 = number; +export type Padding65 = number; +export type Padding66 = number; +export type Padding67 = number; +export type Padding68 = number; +export type Padding69 = number; +export type Padding70 = number; +export type Padding71 = number; +export type Padding72 = number; +export type Padding73 = number; +export type Padding74 = number; +export type Padding75 = number; +export type Padding76 = number; +export type Padding77 = number; +export type Padding78 = number; +export type Padding79 = number; +export type Padding80 = number; +export type Padding81 = number; +export type Padding82 = number; +export type Padding83 = number; +export type Padding84 = number; +export type Padding85 = number; +export type Padding86 = number; +export type Padding87 = number; +export type Padding88 = number; +export type Padding89 = number; +export type Padding90 = number; +export type Padding91 = number; +export type Padding92 = number; +export type Padding93 = number; +export type Padding94 = number; +export type Padding95 = number; +export type Padding96 = number; +export type Padding97 = number; +export type Padding98 = number; +export type Padding99 = number; +export type Padding100 = number; +export type Padding101 = number; +export type Padding102 = number; +export type Padding103 = number; +export type Padding104 = number; +export type Padding105 = number; +export type Padding106 = number; +export type Padding107 = number; +export type Padding108 = number; +export type Padding109 = number; +export type Padding110 = number; +export type Padding111 = number; +export type Padding112 = number; +export type Padding113 = number; +export type Padding114 = number; +export type Padding115 = number; +export type Padding116 = number; +export type Padding117 = number; +export type Padding118 = number; +export type Padding119 = number; +export type Padding120 = number; +export type Padding121 = number; +export type Padding122 = number; +export type Padding123 = number; +export type Padding124 = number; +export type Padding125 = number; +export type Padding126 = number; +export type Padding127 = number; +export type Padding128 = number; +export type Padding129 = number; +export type Padding130 = number; +export type Padding131 = number; +export type Padding132 = number; +export type Padding133 = number; +export type Padding134 = number; +export type Padding135 = number; +export type Padding136 = number; +export type Padding137 = number; +export type Padding138 = number; +export type Padding139 = number; +export type Padding140 = number; +export type Padding141 = number; +export type Padding142 = number; +export type Padding143 = number; +export type Padding144 = number; +export type Padding145 = number; +export type Padding146 = number; +export type Padding147 = number; +export type Padding148 = number; +export type Padding149 = number; +export type Padding150 = number; +export type Padding151 = number; +export type Padding152 = number; +export type Padding153 = number; +export type Padding154 = number; +export type Padding155 = number; +export type Padding156 = number; +export type Padding157 = number; +export type Padding158 = number; +export type Padding159 = number; +export type Padding160 = number; +export type Padding161 = number; +export type Padding162 = number; +export type Padding163 = number; +export type Padding164 = number; +export type Padding165 = number; +export type Padding166 = number; +export type Padding167 = number; +export type Padding168 = number; +export type Padding169 = number; +export type Padding170 = number; +export type Padding171 = number; +export type Padding172 = number; +export type Padding173 = number; +export type Padding174 = number; +export type Padding175 = number; +export type Padding176 = number; +export type Padding177 = number; +export type Padding178 = number; +export type Padding179 = number; +export type Padding180 = number; +export type Padding181 = number; +export type Padding182 = number; +export type Padding183 = number; +export type Padding184 = number; +export type Padding185 = number; +export type Padding186 = number; +export type Padding187 = number; +export type Padding188 = number; +export type Padding189 = number; +export type Padding190 = number; +export type Padding191 = number; +export type Padding192 = number; +export type Padding193 = number; +export type Padding194 = number; +export type Padding195 = number; +export type Padding196 = number; +export type Padding197 = number; +export type Padding198 = number; +export type Padding199 = number; +export type Padding200 = number; +export type Padding201 = number; +export type Padding202 = number; +export type Padding203 = number; +export type Padding204 = number; +export type Padding205 = number; +export type Padding206 = number; +export type Padding207 = number; +export type Padding208 = number; +export type Padding209 = number; +export type Padding210 = number; +export type Padding211 = number; +export type Padding212 = number; +export type Padding213 = number; +export type Padding214 = number; +export type Padding215 = number; +export type Padding216 = number; +export type Padding217 = number; +export type Padding218 = number; +export type Padding219 = number; +export type Padding220 = number; +export type Padding221 = number; +export type Padding222 = number; +export type Padding223 = number; +export type Padding224 = number; +export type Padding225 = number; +export type Padding226 = number; +export type Padding227 = number; +export type Padding228 = number; +export type Padding229 = number; +export type Padding230 = number; +export type Padding231 = number; +export type Padding232 = number; +export type Padding233 = number; +export type Padding234 = number; +export type Padding235 = number; +export type Padding236 = number; +export type Padding237 = number; +export type Padding238 = number; +export type Padding239 = number; +export type Padding240 = number; +export type Padding241 = number; +export type Padding242 = number; +export type Padding243 = number; +export type Padding244 = number; +export type Padding245 = number; +export type Padding246 = number; +export type Padding247 = number; +export type Padding248 = number; +export type Padding249 = number; +export type Padding250 = number; +export type Padding251 = number; +export type Padding252 = number; +export type Padding253 = number; +export type Padding254 = number; +export type Padding255 = number; +export type Padding256 = number; +export type Padding257 = number; +export type Padding258 = number; +export type Padding259 = number; + +export function computeAlpha(x: number): number { + return x + 1; +} + +export function computeBeta(y: number): number { + return computeAlpha(y) + 1; +} +