graphite-code 1.3.0__py3-none-any.whl → 1.4.0__py3-none-any.whl
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.
- graphite/__init__.py +1 -1
- graphite/agent_hooks.py +61 -0
- graphite/cli.py +20 -3
- graphite/doctor.py +2 -2
- graphite/extract/ast.py +121 -29
- graphite/extract/pyscope.py +57 -7
- graphite/hookinstall.py +17 -0
- graphite/init.py +1 -0
- graphite/resolve.py +132 -1
- {graphite_code-1.3.0.dist-info → graphite_code-1.4.0.dist-info}/METADATA +2 -2
- {graphite_code-1.3.0.dist-info → graphite_code-1.4.0.dist-info}/RECORD +14 -14
- {graphite_code-1.3.0.dist-info → graphite_code-1.4.0.dist-info}/WHEEL +0 -0
- {graphite_code-1.3.0.dist-info → graphite_code-1.4.0.dist-info}/entry_points.txt +0 -0
- {graphite_code-1.3.0.dist-info → graphite_code-1.4.0.dist-info}/licenses/LICENSE +0 -0
graphite/__init__.py
CHANGED
|
@@ -38,4 +38,4 @@ __all__ = ["__version__"]
|
|
|
38
38
|
#: Coarse and hand-maintained by construction. It is a release label, not
|
|
39
39
|
#: evidence that a given fix is present -- to answer that, survey for the marker
|
|
40
40
|
#: the fix introduced, or compare `graphite --version` fingerprints.
|
|
41
|
-
__version__ = "1.
|
|
41
|
+
__version__ = "1.4.0"
|
graphite/agent_hooks.py
CHANGED
|
@@ -968,6 +968,54 @@ def _forget_what_may_be_set_again(assigned: dict[str, str], argv: list[str], pow
|
|
|
968
968
|
assigned.pop(named.group().lower() if powershell else named.group(), None)
|
|
969
969
|
|
|
970
970
|
|
|
971
|
+
# PowerShell commands whose output is the files (or folders) of a path, so that a
|
|
972
|
+
# `Select-String` after a pipe searches what they hand it (#84).
|
|
973
|
+
_FILE_LISTINGS = frozenset({"get-childitem", "gci", "ls", "dir"})
|
|
974
|
+
# The searches that take files from the pipeline. `grep` does not: it reads the
|
|
975
|
+
# lines of what is piped, so `ls | grep name` filters a list of names.
|
|
976
|
+
_POWERSHELL_SEARCHES = frozenset({"select-string", "sls"})
|
|
977
|
+
# What may stand between such a listing and the search and still hand the search
|
|
978
|
+
# files: a stage that only keeps or orders them. Any other stage (`ForEach-Object`,
|
|
979
|
+
# `Out-String`, `Select-Object -ExpandProperty`) may make text, and ends the chain.
|
|
980
|
+
_FILE_KEEPING_STAGES = frozenset({
|
|
981
|
+
"where-object", "where", "?", "select-object", "select", "sort-object", "sort",
|
|
982
|
+
})
|
|
983
|
+
# Listing flags that take a word, so the word is not a path. `-Path` and
|
|
984
|
+
# `-LiteralPath` are the exceptions: their word IS one.
|
|
985
|
+
_LISTING_VALUE_FLAGS = frozenset({
|
|
986
|
+
"-filter", "-include", "-exclude", "-depth", "-attributes",
|
|
987
|
+
})
|
|
988
|
+
_LISTING_PATH_FLAGS = frozenset({"-path", "-literalpath"})
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _listed_paths(name: str, rest: list[str]) -> list[str] | None:
|
|
992
|
+
"""The paths a file listing names, else None where `name` lists no files.
|
|
993
|
+
|
|
994
|
+
An empty list is a listing of the current directory. `-Name` makes the
|
|
995
|
+
listing text, and what is piped on is then a filter of it.
|
|
996
|
+
"""
|
|
997
|
+
if name not in _FILE_LISTINGS:
|
|
998
|
+
return None
|
|
999
|
+
paths: list[str] = []
|
|
1000
|
+
expecting = False
|
|
1001
|
+
skipping = False
|
|
1002
|
+
for token in rest:
|
|
1003
|
+
lowered = token.lower()
|
|
1004
|
+
if expecting or skipping:
|
|
1005
|
+
if expecting:
|
|
1006
|
+
paths.append(token)
|
|
1007
|
+
expecting = skipping = False
|
|
1008
|
+
elif lowered == "-name" or lowered == "-n":
|
|
1009
|
+
return None
|
|
1010
|
+
elif lowered in _LISTING_PATH_FLAGS:
|
|
1011
|
+
expecting = True
|
|
1012
|
+
elif lowered in _LISTING_VALUE_FLAGS:
|
|
1013
|
+
skipping = True
|
|
1014
|
+
elif not token.startswith("-"):
|
|
1015
|
+
paths.append(token)
|
|
1016
|
+
return paths
|
|
1017
|
+
|
|
1018
|
+
|
|
971
1019
|
def _bash_searches(
|
|
972
1020
|
command: str, root: Path, msys: bool = False, cwd: Path | None = None, powershell: bool = False
|
|
973
1021
|
) -> list[_Search]:
|
|
@@ -1024,9 +1072,11 @@ def _bash_searches(
|
|
|
1024
1072
|
base, placed = start, start_placed # `base` is where the shell is
|
|
1025
1073
|
moved = False # a `cd`, or a command the hook does not model, has run in this shell
|
|
1026
1074
|
pipeline_base, pipeline_placed = base, placed # where the pipeline being read started
|
|
1075
|
+
listed: list[str] | None = None # the paths of the file listing whose output the stage before gives
|
|
1027
1076
|
for head in _bash_command_heads(lines, powershell):
|
|
1028
1077
|
piped = head.before in ("|", "|&") # `||` is or-else, not a pipe
|
|
1029
1078
|
if not piped:
|
|
1079
|
+
listed = None
|
|
1030
1080
|
if head.before != "&&":
|
|
1031
1081
|
base, placed = (None, False) if moved else (start, start_placed)
|
|
1032
1082
|
pipeline_base, pipeline_placed = base, placed
|
|
@@ -1040,6 +1090,16 @@ def _bash_searches(
|
|
|
1040
1090
|
if len(head.argv) == 1 and _NESTED_RE.fullmatch(head.argv[0]):
|
|
1041
1091
|
continue # a subshell standing alone
|
|
1042
1092
|
if piped and _command_name(head.argv[0]) != "xargs":
|
|
1093
|
+
stage = _command_name(head.argv[0])
|
|
1094
|
+
if listed is not None and stage in _POWERSHELL_SEARCHES:
|
|
1095
|
+
# `Get-ChildItem -Recurse src | Select-String name` searches
|
|
1096
|
+
# the files it is handed (#84), as `xargs grep` does.
|
|
1097
|
+
found = _search_in(stage, [_expanded(t, assigned, powershell) for t in head.argv[1:]], here, msys)
|
|
1098
|
+
if found is not None:
|
|
1099
|
+
found = found._replace(paths=_located(listed, here, msys))
|
|
1100
|
+
searches.append(found if here_placed or cwd is None else _unplaced(found, cwd))
|
|
1101
|
+
if stage not in _FILE_KEEPING_STAGES:
|
|
1102
|
+
listed = None
|
|
1043
1103
|
continue # a filter: it reads what the command before it wrote
|
|
1044
1104
|
assignments = _assignments(head.argv, powershell)
|
|
1045
1105
|
if assignments is not None:
|
|
@@ -1063,6 +1123,7 @@ def _bash_searches(
|
|
|
1063
1123
|
continue
|
|
1064
1124
|
name = _command_name(argv[0])
|
|
1065
1125
|
rest = [_expanded(token, assigned, powershell) for token in argv[1:]]
|
|
1126
|
+
listed = _listed_paths(name, rest) if len(argv) == len(written) else None
|
|
1066
1127
|
# A `cd` behind a wrapper is not the shell's: `env cd docs` moves a
|
|
1067
1128
|
# program that then exits, and `! cd docs && ...` goes on only when
|
|
1068
1129
|
# the `cd` failed. It is read below, as a command that may move.
|
graphite/cli.py
CHANGED
|
@@ -255,12 +255,17 @@ def _write_json(path: Path, data: dict[str, Any]) -> None:
|
|
|
255
255
|
atomic_write_json(path, data, indent=2)
|
|
256
256
|
|
|
257
257
|
|
|
258
|
-
def
|
|
259
|
-
|
|
260
|
-
root = Path(args.path).resolve()
|
|
258
|
+
def _require_existing(root: Path) -> None:
|
|
259
|
+
"""Exit 1 with `path not found` for a path that is not there."""
|
|
261
260
|
if not root.exists():
|
|
262
261
|
print(f"[graphite] path not found: {root}", file=sys.stderr)
|
|
263
262
|
raise SystemExit(1)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _scan(args: argparse.Namespace, cfg: Config) -> tuple[dict[str, Any], list[Any]]:
|
|
266
|
+
cfg = cfg.canonical_graph()
|
|
267
|
+
root = Path(args.path).resolve()
|
|
268
|
+
_require_existing(root)
|
|
264
269
|
top = _root_above(root)
|
|
265
270
|
if top is not None:
|
|
266
271
|
raise ValueError(_below_a_root(root, top))
|
|
@@ -727,6 +732,9 @@ def cmd_build(args: argparse.Namespace) -> int:
|
|
|
727
732
|
top = _root_above(asked)
|
|
728
733
|
if top is not None:
|
|
729
734
|
raise ValueError(_below_a_root(asked, top))
|
|
735
|
+
# A path that is not there is refused as `scan` refuses it (#88): the lock
|
|
736
|
+
# below would otherwise make it, and a detached child could not start in it.
|
|
737
|
+
_require_existing(asked)
|
|
730
738
|
if getattr(args, "detach", False):
|
|
731
739
|
root = Path(args.path).resolve()
|
|
732
740
|
# Deliberately omits --detach: the child must build, not re-spawn.
|
|
@@ -839,6 +847,10 @@ def cmd_doctor(args: argparse.Namespace) -> int:
|
|
|
839
847
|
root = Path(args.path).resolve()
|
|
840
848
|
if not root.is_dir():
|
|
841
849
|
raise ValueError("doctor path must be an existing directory")
|
|
850
|
+
# The report is about the repository the folder is in (#89), as the read
|
|
851
|
+
# commands are: from a subfolder the graph is the root's, and so are the
|
|
852
|
+
# incidents. A root graphite does not take (`repository_root`) is not followed.
|
|
853
|
+
root = repository_root(root)
|
|
842
854
|
cfg = _project_scoped_config(args, root)
|
|
843
855
|
report = run_doctor(
|
|
844
856
|
root,
|
|
@@ -1204,6 +1216,11 @@ def cmd_init(args: argparse.Namespace) -> int:
|
|
|
1204
1216
|
extra += f", reason={hooks['reason']}"
|
|
1205
1217
|
if hooks.get("relocated"):
|
|
1206
1218
|
extra += f", relocated={', '.join(hooks['relocated'])}"
|
|
1219
|
+
if hooks.get("chain_slots"):
|
|
1220
|
+
# Said in either install order: with graphite first nothing is in
|
|
1221
|
+
# these slots yet, and this is where another tool's hook goes.
|
|
1222
|
+
files = ", ".join(slot["file"] for slot in hooks["chain_slots"].values())
|
|
1223
|
+
extra += f"; another tool's hook for the same event belongs in {files} there, run first"
|
|
1207
1224
|
print(f" - hooks: {hooks.get('action')} ({hooks.get('path')}{extra})")
|
|
1208
1225
|
allowlist = result["allowlist"]
|
|
1209
1226
|
if allowlist.get("changed"):
|
graphite/doctor.py
CHANGED
|
@@ -20,7 +20,7 @@ from .daemon import read_daemon_status
|
|
|
20
20
|
from .daemon_health import HealthOptions, evaluate_daemon_health
|
|
21
21
|
from .freshness import FreshnessLimitError, check_graph_freshness
|
|
22
22
|
from .git import GitError, GitRunner
|
|
23
|
-
from .hookinstall import DEFAULT_HOOKS_DIRNAME, hook_shim_present, hooks_dir
|
|
23
|
+
from .hookinstall import DEFAULT_HOOKS_DIRNAME, chain_slots, hook_shim_present, hooks_dir
|
|
24
24
|
from .init import gitignored_managed_paths, managed_doc_paths
|
|
25
25
|
from .hookshim import TRIGGERS
|
|
26
26
|
from .llm import canonical_provider_name
|
|
@@ -179,7 +179,7 @@ def check_hooks(root: Path) -> DoctorCheck:
|
|
|
179
179
|
installed = all(hook_shim_present(hdir / hook) for hook in TRIGGERS)
|
|
180
180
|
configured = _hooks_path_configured(root)
|
|
181
181
|
custom_hooks_dir = hdir != root / DEFAULT_HOOKS_DIRNAME
|
|
182
|
-
details = {"onboarded": True, "custom_hooks_dir": custom_hooks_dir, "hookspath_configured": configured, "trampolines_installed": installed}
|
|
182
|
+
details = {"onboarded": True, "custom_hooks_dir": custom_hooks_dir, "hookspath_configured": configured, "trampolines_installed": installed, "chain_slots": chain_slots(hdir)}
|
|
183
183
|
|
|
184
184
|
if configured and installed:
|
|
185
185
|
return DoctorCheck("hooks", "Hooks", "ready", "Git hooks are installed and enforced.", details)
|
graphite/extract/ast.py
CHANGED
|
@@ -6,9 +6,10 @@ import importlib
|
|
|
6
6
|
import re
|
|
7
7
|
import sys
|
|
8
8
|
import unicodedata
|
|
9
|
+
import dataclasses
|
|
9
10
|
from dataclasses import dataclass, field
|
|
10
11
|
from pathlib import Path
|
|
11
|
-
from typing import Any, Collection, Final, Sequence
|
|
12
|
+
from typing import Any, Collection, Final, Iterable, Sequence
|
|
12
13
|
|
|
13
14
|
from ..cache import Cache
|
|
14
15
|
from ..config import Config
|
|
@@ -214,8 +215,10 @@ def _make_id(*parts: str) -> str:
|
|
|
214
215
|
Known residual: two DIFFERENT part tuples can still produce one id when the
|
|
215
216
|
only difference is a separator versus a literal underscore — `a/b.py` and
|
|
216
217
|
`a_b.py` both reach `a_b_py`, and both are ambiguity-free by this test.
|
|
217
|
-
Closing it would mean discriminating every path containing an
|
|
218
|
-
which is most of `tests/`.
|
|
218
|
+
Closing it here would mean discriminating every path containing an
|
|
219
|
+
underscore, which is most of `tests/`. For FILE ids it is closed another
|
|
220
|
+
way (#82): `_colliding_file_ids` marks the paths of a tree that actually
|
|
221
|
+
collide and no others. For a definition's id within one file it remains.
|
|
219
222
|
"""
|
|
220
223
|
kept = [p for p in parts if p]
|
|
221
224
|
combined = unicodedata.normalize("NFKC", "_".join(p.strip("_.") for p in kept))
|
|
@@ -262,7 +265,52 @@ def _scoped_id(file_id: str, qualname: str, salt: int = 0) -> str:
|
|
|
262
265
|
return f"{readable[: _MAX_ID_LEN - len(marker) - 1].rstrip('_')}_{marker}"
|
|
263
266
|
|
|
264
267
|
|
|
265
|
-
def _file_node_id(rel_path: str) -> str:
|
|
268
|
+
def _file_node_id(rel_path: str, source_index: SourceIndex | None = None) -> str:
|
|
269
|
+
"""The id of a file's node: its plain id, or the one the tree gave it (#82).
|
|
270
|
+
|
|
271
|
+
A path whose plain id another path of the tree also has is given a marker
|
|
272
|
+
by `_colliding_file_ids`, which `extract_all` records on the source index.
|
|
273
|
+
Everything else keeps its plain id.
|
|
274
|
+
"""
|
|
275
|
+
if source_index is not None and source_index.file_ids:
|
|
276
|
+
marked = source_index.file_ids.get(rel_path)
|
|
277
|
+
if marked is not None:
|
|
278
|
+
return marked
|
|
279
|
+
return _plain_file_node_id(rel_path)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _colliding_file_ids(rel_paths: Iterable[str]) -> dict[str, str]:
|
|
283
|
+
"""rel path -> file node id, for each path whose plain id another path has.
|
|
284
|
+
|
|
285
|
+
`a/b.py`, `a_b.py` and `a/b_py` all slug to `a_b_py`, a loss `_make_id`
|
|
286
|
+
cannot see from one path. Every member of such a group is marked, with a
|
|
287
|
+
hash of its own exact path, so no id depends on which of them is listed
|
|
288
|
+
first or on whether the others are still there. A path that collides with
|
|
289
|
+
nothing is not in the result: ids of files without a twin do not move.
|
|
290
|
+
"""
|
|
291
|
+
groups: dict[str, list[str]] = {}
|
|
292
|
+
for rel_path in rel_paths:
|
|
293
|
+
groups.setdefault(_plain_file_node_id(rel_path), []).append(rel_path)
|
|
294
|
+
taken = set(groups)
|
|
295
|
+
marked: dict[str, str] = {}
|
|
296
|
+
for plain, members in sorted(groups.items()):
|
|
297
|
+
if len(members) < 2:
|
|
298
|
+
continue
|
|
299
|
+
for rel_path in sorted(members):
|
|
300
|
+
salt = 0
|
|
301
|
+
while True:
|
|
302
|
+
tag = f"file-path\x00{rel_path}" + (f"\x00{salt}" if salt else "")
|
|
303
|
+
marker = hashlib.blake2s(tag.encode("utf-8"), digest_size=_ID_DISCRIMINATOR_LEN // 2).hexdigest()
|
|
304
|
+
candidate = f"{plain[: _MAX_ID_LEN - len(marker) - 1].rstrip('_')}_{marker}"
|
|
305
|
+
if candidate not in taken:
|
|
306
|
+
break
|
|
307
|
+
salt += 1
|
|
308
|
+
taken.add(candidate)
|
|
309
|
+
marked[rel_path] = candidate
|
|
310
|
+
return marked
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _plain_file_node_id(rel_path: str) -> str:
|
|
266
314
|
"""Stable node id for a file: a slug of the FULL repo-relative path AND name.
|
|
267
315
|
|
|
268
316
|
Two claims this docstring used to make were false, and both were disproved
|
|
@@ -396,7 +444,7 @@ def _extract_ts_js(file_id: str, rel_path: str, source: bytes, tree: Any, source
|
|
|
396
444
|
root = tree.root_node
|
|
397
445
|
|
|
398
446
|
def _line(node: Any) -> int:
|
|
399
|
-
return
|
|
447
|
+
return node.start_point[0] + 1
|
|
400
448
|
|
|
401
449
|
def _name(node: Any) -> str | None:
|
|
402
450
|
# identifiers, property_identifier, type_identifier, etc.
|
|
@@ -424,7 +472,7 @@ def _extract_ts_js(file_id: str, rel_path: str, source: bytes, tree: Any, source
|
|
|
424
472
|
def _anon_scope(node: Any) -> _Scope:
|
|
425
473
|
syn = _safe_label(_synthetic_fn_name(node, source))
|
|
426
474
|
line = _line(node)
|
|
427
|
-
col =
|
|
475
|
+
col = node.start_point[1] + 1
|
|
428
476
|
mid = _make_id(file_id, syn, f"l{line}", f"c{col}")
|
|
429
477
|
pending = _node(mid, "function", syn, rel_path, line, extra={"anonymous": True})
|
|
430
478
|
return _Scope(mid, pending=pending)
|
|
@@ -517,7 +565,7 @@ def _extract_ts_js(file_id: str, rel_path: str, source: bytes, tree: Any, source
|
|
|
517
565
|
result.edges.append(
|
|
518
566
|
_edge(
|
|
519
567
|
file_id,
|
|
520
|
-
_file_node_id(resolved.rel_path),
|
|
568
|
+
_file_node_id(resolved.rel_path, source_index),
|
|
521
569
|
"imports",
|
|
522
570
|
rel_path,
|
|
523
571
|
_line(node),
|
|
@@ -540,7 +588,7 @@ def _extract_ts_js(file_id: str, rel_path: str, source: bytes, tree: Any, source
|
|
|
540
588
|
result.edges.append(
|
|
541
589
|
_edge(
|
|
542
590
|
file_id,
|
|
543
|
-
_file_node_id(required.rel_path),
|
|
591
|
+
_file_node_id(required.rel_path, source_index),
|
|
544
592
|
"imports",
|
|
545
593
|
rel_path,
|
|
546
594
|
_line(node),
|
|
@@ -646,7 +694,7 @@ def _extract_ts_js(file_id: str, rel_path: str, source: bytes, tree: Any, source
|
|
|
646
694
|
result.edges.append(
|
|
647
695
|
_edge(
|
|
648
696
|
file_id,
|
|
649
|
-
_file_node_id(edge.target),
|
|
697
|
+
_file_node_id(edge.target, source_index),
|
|
650
698
|
edge.relation,
|
|
651
699
|
rel_path,
|
|
652
700
|
edge.line,
|
|
@@ -724,7 +772,7 @@ def _collect_ts_import_symbols(root: Any, rel_path: str, source_index: SourceInd
|
|
|
724
772
|
# Resolved module: every name it binds is proven in-repo, regardless
|
|
725
773
|
# of binding form -- must win over an _EXTERNAL_GLOBALS collision.
|
|
726
774
|
in_repo.update(_iter_bound_local_names(clause))
|
|
727
|
-
target_file_id = _file_node_id(resolved.rel_path)
|
|
775
|
+
target_file_id = _file_node_id(resolved.rel_path, source_index)
|
|
728
776
|
for local, original in _iter_named_imports(clause):
|
|
729
777
|
symbols[local] = _make_id(target_file_id, original)
|
|
730
778
|
for child in clause.children:
|
|
@@ -766,7 +814,7 @@ def _collect_ts_import_symbols(root: Any, rel_path: str, source_index: SourceInd
|
|
|
766
814
|
external.update(bound)
|
|
767
815
|
return
|
|
768
816
|
in_repo.update(bound)
|
|
769
|
-
target_file_id = _file_node_id(resolved.rel_path)
|
|
817
|
+
target_file_id = _file_node_id(resolved.rel_path, source_index)
|
|
770
818
|
if pairs:
|
|
771
819
|
for local, original in pairs:
|
|
772
820
|
symbols[local] = _make_id(target_file_id, original)
|
|
@@ -1314,7 +1362,7 @@ def _python_import_bindings(
|
|
|
1314
1362
|
out.append((root, "unknown", None))
|
|
1315
1363
|
elif "." not in module:
|
|
1316
1364
|
resolved = source_index.resolve_python_module(rel_path, module)
|
|
1317
|
-
out.append((module, "alias", _file_node_id(resolved)) if resolved else (module, "external", None))
|
|
1365
|
+
out.append((module, "alias", _file_node_id(resolved, source_index)) if resolved else (module, "external", None))
|
|
1318
1366
|
elif source_index.resolve_python_module(rel_path, root) is None:
|
|
1319
1367
|
# `import pkg.sub` binds only the root name `pkg`. Mark it
|
|
1320
1368
|
# external ONLY if that root does not resolve in-repo -- a
|
|
@@ -1331,7 +1379,7 @@ def _python_import_bindings(
|
|
|
1331
1379
|
out.append((local, "unknown", None))
|
|
1332
1380
|
continue
|
|
1333
1381
|
resolved = source_index.resolve_python_module(rel_path, module)
|
|
1334
|
-
out.append((local, "alias", _file_node_id(resolved)) if resolved else (local, "external", None))
|
|
1382
|
+
out.append((local, "alias", _file_node_id(resolved, source_index)) if resolved else (local, "external", None))
|
|
1335
1383
|
elif node.type == "import_from_statement":
|
|
1336
1384
|
modules = _python_import_modules(node)
|
|
1337
1385
|
if not modules:
|
|
@@ -1359,11 +1407,14 @@ def _python_import_bindings(
|
|
|
1359
1407
|
sub = f"{base_module}.{original}" if base_module else original
|
|
1360
1408
|
as_module = source_index.resolve_python_module(rel_path, sub, dots)
|
|
1361
1409
|
if as_module:
|
|
1362
|
-
out.append((local, "alias", _file_node_id(as_module)))
|
|
1410
|
+
out.append((local, "alias", _file_node_id(as_module, source_index)))
|
|
1363
1411
|
continue
|
|
1364
1412
|
parent = source_index.resolve_python_module(rel_path, base_module, dots)
|
|
1365
1413
|
if parent:
|
|
1366
|
-
|
|
1414
|
+
# A package that only re-exports the name does not define it:
|
|
1415
|
+
# bind to the file that does (#87).
|
|
1416
|
+
defining, defined_as = source_index.follow_python_reexport(parent, original)
|
|
1417
|
+
out.append((local, "symbol", _make_id(_file_node_id(defining, source_index), defined_as)))
|
|
1367
1418
|
else:
|
|
1368
1419
|
out.append((local, "external", None))
|
|
1369
1420
|
return out
|
|
@@ -1459,6 +1510,9 @@ def _python_bare_call(
|
|
|
1459
1510
|
return ids[(resolution.qualname, resolution.module_level)], confidence
|
|
1460
1511
|
if resolution.kind == "symbol" and resolution.target is not None:
|
|
1461
1512
|
return resolution.target, confidence
|
|
1513
|
+
if resolution.kind == "member" and resolution.target is not None and resolution.qualname is not None:
|
|
1514
|
+
# `t = mod.attr; t()`: the call `mod.attr()` makes (#90).
|
|
1515
|
+
return _make_id(resolution.target, resolution.qualname), confidence
|
|
1462
1516
|
if resolution.kind in ("value", "alias", "ambiguous-import") and table.module_defines(name):
|
|
1463
1517
|
return ids[(f"<local>.{name}", False)], confidence
|
|
1464
1518
|
return _resolve_call(file_id, name), confidence
|
|
@@ -1510,7 +1564,7 @@ def _extract_python(file_id: str, rel_path: str, _source: bytes, tree: Any, sour
|
|
|
1510
1564
|
root = tree.root_node
|
|
1511
1565
|
|
|
1512
1566
|
def _line(node: Any) -> int:
|
|
1513
|
-
return
|
|
1567
|
+
return node.start_point[0] + 1
|
|
1514
1568
|
|
|
1515
1569
|
result.nodes.append(_node(file_id, "file", Path(rel_path).name, rel_path))
|
|
1516
1570
|
|
|
@@ -1582,7 +1636,15 @@ def _extract_python(file_id: str, rel_path: str, _source: bytes, tree: Any, sour
|
|
|
1582
1636
|
for arg in base.children:
|
|
1583
1637
|
if arg.type.endswith("identifier") and arg.text:
|
|
1584
1638
|
base_name = arg.text.decode("utf-8", errors="ignore")
|
|
1585
|
-
|
|
1639
|
+
base_target = _make_id(base_name)
|
|
1640
|
+
if body_scope is not None and body_scope.parent is not None:
|
|
1641
|
+
# Where the source states which class the name
|
|
1642
|
+
# is, the edge ends at that class (#86); any
|
|
1643
|
+
# other base keeps its placeholder.
|
|
1644
|
+
named = resolve(body_scope.parent, base_name)
|
|
1645
|
+
if named.kind in ("def", "symbol"):
|
|
1646
|
+
base_target = _python_definition_id(named, ids) or base_target
|
|
1647
|
+
result.edges.append(_edge(did, base_target, "inherits", rel_path, _line(arg)))
|
|
1586
1648
|
walk_children(node, did, scope_id)
|
|
1587
1649
|
elif node.type in ("import_statement", "import_from_statement"):
|
|
1588
1650
|
for module, dots in _python_import_modules(node):
|
|
@@ -1593,7 +1655,7 @@ def _extract_python(file_id: str, rel_path: str, _source: bytes, tree: Any, sour
|
|
|
1593
1655
|
)
|
|
1594
1656
|
if resolved:
|
|
1595
1657
|
result.edges.append(_edge(
|
|
1596
|
-
file_id, _file_node_id(resolved), "imports", rel_path,
|
|
1658
|
+
file_id, _file_node_id(resolved, source_index), "imports", rel_path,
|
|
1597
1659
|
_line(node), confidence="EXACT_IMPORT",
|
|
1598
1660
|
))
|
|
1599
1661
|
# `import pkg.sub` also imports `pkg` (#55). Deliberately NOT
|
|
@@ -1606,7 +1668,7 @@ def _extract_python(file_id: str, rel_path: str, _source: bytes, tree: Any, sour
|
|
|
1606
1668
|
if node.type == "import_statement" else ()
|
|
1607
1669
|
):
|
|
1608
1670
|
result.edges.append(_edge(
|
|
1609
|
-
file_id, _file_node_id(ancestor), "imports", rel_path,
|
|
1671
|
+
file_id, _file_node_id(ancestor, source_index), "imports", rel_path,
|
|
1610
1672
|
_line(node), confidence="EXACT_IMPORT",
|
|
1611
1673
|
))
|
|
1612
1674
|
else:
|
|
@@ -1616,7 +1678,7 @@ def _extract_python(file_id: str, rel_path: str, _source: bytes, tree: Any, sour
|
|
|
1616
1678
|
))
|
|
1617
1679
|
for sub in _python_from_import_submodules(node, rel_path, source_index):
|
|
1618
1680
|
result.edges.append(_edge(
|
|
1619
|
-
file_id, _file_node_id(sub), "imports", rel_path,
|
|
1681
|
+
file_id, _file_node_id(sub, source_index), "imports", rel_path,
|
|
1620
1682
|
_line(node), confidence="EXACT_IMPORT",
|
|
1621
1683
|
))
|
|
1622
1684
|
walk_children(node, parent_id, scope_id)
|
|
@@ -1628,8 +1690,14 @@ def _extract_python(file_id: str, rel_path: str, _source: bytes, tree: Any, sour
|
|
|
1628
1690
|
bare, obj_name, attr = _python_call_target(func) if func is not None else (None, None, None)
|
|
1629
1691
|
edge = None
|
|
1630
1692
|
scope = table.scope_of_call(node)
|
|
1631
|
-
|
|
1632
|
-
|
|
1693
|
+
# The list holds the builtins of every language, so a name on it is
|
|
1694
|
+
# left without an edge only where the file binds nothing by that
|
|
1695
|
+
# name: a class `Error` or a function `make` of this file or one it
|
|
1696
|
+
# imports is called like any other (#85).
|
|
1697
|
+
resolution = resolve(scope, bare) if bare else None
|
|
1698
|
+
if bare and resolution is not None and (
|
|
1699
|
+
bare not in _LANGUAGE_BUILTIN_GLOBALS or resolution.kind != "none"
|
|
1700
|
+
):
|
|
1633
1701
|
target, confidence = _python_bare_call(file_id, bare, resolution, table, ids)
|
|
1634
1702
|
edge = _edge(scope_id, target, "calls", rel_path, _line(node), confidence=confidence)
|
|
1635
1703
|
elif attr:
|
|
@@ -1716,7 +1784,7 @@ def _extract_go(file_id: str, rel_path: str, source: bytes, tree: Any) -> Extrac
|
|
|
1716
1784
|
root = tree.root_node
|
|
1717
1785
|
|
|
1718
1786
|
def _line(node: Any) -> int:
|
|
1719
|
-
return
|
|
1787
|
+
return node.start_point[0] + 1
|
|
1720
1788
|
|
|
1721
1789
|
def _text(node: Any) -> str | None:
|
|
1722
1790
|
return node.text.decode("utf-8", errors="ignore") if node is not None and node.text else None
|
|
@@ -1814,7 +1882,7 @@ def _extract_rust(
|
|
|
1814
1882
|
root = tree.root_node
|
|
1815
1883
|
|
|
1816
1884
|
def _line(node: Any) -> int:
|
|
1817
|
-
return
|
|
1885
|
+
return node.start_point[0] + 1
|
|
1818
1886
|
|
|
1819
1887
|
def _text(node: Any) -> str | None:
|
|
1820
1888
|
return node.text.decode("utf-8", errors="ignore") if node is not None and node.text else None
|
|
@@ -1887,7 +1955,7 @@ def _extract_rust(
|
|
|
1887
1955
|
result.edges.append(
|
|
1888
1956
|
_edge(
|
|
1889
1957
|
file_id,
|
|
1890
|
-
_file_node_id(resolved),
|
|
1958
|
+
_file_node_id(resolved, source_index),
|
|
1891
1959
|
"imports",
|
|
1892
1960
|
rel_path,
|
|
1893
1961
|
_line(node),
|
|
@@ -1913,7 +1981,7 @@ def _extract_rust(
|
|
|
1913
1981
|
result.edges.append(
|
|
1914
1982
|
_edge(
|
|
1915
1983
|
file_id,
|
|
1916
|
-
_file_node_id(resolution.rel_path),
|
|
1984
|
+
_file_node_id(resolution.rel_path, source_index),
|
|
1917
1985
|
"imports",
|
|
1918
1986
|
rel_path,
|
|
1919
1987
|
_line(node),
|
|
@@ -2058,6 +2126,18 @@ def extract_file(entry: FileEntry, cfg: Config, cache: Cache | None = None, sour
|
|
|
2058
2126
|
# Only resolver-consulting languages pay this invalidation: Go and Rust
|
|
2059
2127
|
# extraction is file-local and cannot go stale this way.
|
|
2060
2128
|
cache_language = f"{cache_language}:fs{source_index.file_set_digest()}"
|
|
2129
|
+
if entry.language == "python":
|
|
2130
|
+
# A name imported through a package is bound to the file the
|
|
2131
|
+
# package's `__init__.py` re-exports it from, and that file's bytes
|
|
2132
|
+
# are not the importer's (#87).
|
|
2133
|
+
cache_language = f"{cache_language}:rx{source_index.python_reexport_digest()}"
|
|
2134
|
+
if source_index is not None and source_index.file_ids:
|
|
2135
|
+
# A file node id is the plain one unless another path shares it, and an
|
|
2136
|
+
# import edge names its target by that id; so a cached extraction is
|
|
2137
|
+
# good only for the same set of marked ids (#82). A tree with no such
|
|
2138
|
+
# pair adds nothing here, and its keys are what they were.
|
|
2139
|
+
marked = "\0".join(f"{path}\0{file_id}" for path, file_id in sorted(source_index.file_ids.items()))
|
|
2140
|
+
cache_language = f"{cache_language}:fid{hashlib.blake2s(marked.encode('utf-8'), digest_size=8).hexdigest()}"
|
|
2061
2141
|
# What is cached names the file: every node id and `source_file` in it is
|
|
2062
2142
|
# built from the path. Keyed on the bytes and the language alone, two files
|
|
2063
2143
|
# with the same bytes shared one entry, so one of them was answered with
|
|
@@ -2070,7 +2150,7 @@ def extract_file(entry: FileEntry, cfg: Config, cache: Cache | None = None, sour
|
|
|
2070
2150
|
return _result_from_dict(cached)
|
|
2071
2151
|
|
|
2072
2152
|
rel_path = entry.rel_path
|
|
2073
|
-
file_id = _file_node_id(rel_path)
|
|
2153
|
+
file_id = _file_node_id(rel_path, source_index)
|
|
2074
2154
|
|
|
2075
2155
|
try:
|
|
2076
2156
|
with open(entry.abs_path, "rb") as f:
|
|
@@ -2134,6 +2214,9 @@ def extract_all(entries: list[FileEntry], cfg: Config, cache: Cache | None = Non
|
|
|
2134
2214
|
results: list[ExtractionResult] = []
|
|
2135
2215
|
error_records: list[dict[str, Any]] = []
|
|
2136
2216
|
source_index = SourceIndex.from_entries(entries, cfg)
|
|
2217
|
+
file_ids = _colliding_file_ids(entry.rel_path for entry in entries)
|
|
2218
|
+
if file_ids:
|
|
2219
|
+
source_index = dataclasses.replace(source_index, file_ids=file_ids)
|
|
2137
2220
|
if cfg.workers <= 1:
|
|
2138
2221
|
for entry in entries:
|
|
2139
2222
|
result = extract_file(entry, cfg, cache, source_index)
|
|
@@ -2627,8 +2710,17 @@ def _resolve_method_dispatch(
|
|
|
2627
2710
|
# Which file each definition lives in, and which files each file imports
|
|
2628
2711
|
# DIRECTLY. Both are needed to answer "could this caller have reached that
|
|
2629
2712
|
# definition at all" -- see the reachability filter below (#54).
|
|
2713
|
+
# The id a path's file node was given is read off that node: a path whose
|
|
2714
|
+
# plain id another path has was given another (#82).
|
|
2715
|
+
file_node_of_path: dict[str, str] = {
|
|
2716
|
+
n["source_file"]: n["id"] for n in nodes if n.get("kind") == "file" and n.get("source_file")
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
def file_node_id_of(path: str) -> str:
|
|
2720
|
+
return file_node_of_path.get(path) or _file_node_id(path)
|
|
2721
|
+
|
|
2630
2722
|
file_of_node: dict[str, str] = {
|
|
2631
|
-
n["id"]:
|
|
2723
|
+
n["id"]: file_node_id_of(n["source_file"]) for n in nodes if n.get("source_file")
|
|
2632
2724
|
}
|
|
2633
2725
|
# Which interop family each definition belongs to, for the family gate below.
|
|
2634
2726
|
family_of_node: dict[str, str | None] = {
|
|
@@ -2733,7 +2825,7 @@ def _resolve_method_dispatch(
|
|
|
2733
2825
|
# reachable definition now resolves instead of being abandoned as
|
|
2734
2826
|
# "too generic to guess".
|
|
2735
2827
|
if candidates and _dispatch_is_gated(e.get("source_file")):
|
|
2736
|
-
caller_file =
|
|
2828
|
+
caller_file = file_node_id_of(e["source_file"])
|
|
2737
2829
|
reachable: set[str] | frozenset[str] = imports_by_file.get(caller_file, frozenset())
|
|
2738
2830
|
in_file = methods_by_name_and_file[name]
|
|
2739
2831
|
candidates = {
|
graphite/extract/pyscope.py
CHANGED
|
@@ -112,7 +112,8 @@ class PyScope:
|
|
|
112
112
|
class Resolution:
|
|
113
113
|
"""What a bare name denotes at one call site."""
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
#: "member" is `qualname`, an attribute of the in-repo module file `target` (#90).
|
|
116
|
+
kind: str # "def" | "symbol" | "alias" | "member" | "external" | "ambiguous-import" | "value" | "none"
|
|
116
117
|
qualname: str | None = None
|
|
117
118
|
module_level: bool = False
|
|
118
119
|
target: str | None = None
|
|
@@ -211,6 +212,25 @@ def _value_origin(node: Any) -> tuple[str, str] | None:
|
|
|
211
212
|
return None
|
|
212
213
|
|
|
213
214
|
|
|
215
|
+
def _alias_origin(node: Any) -> tuple[str, str] | None:
|
|
216
|
+
"""("name", text) for a bare name, ("attr", "obj.attr") for one attribute of a name.
|
|
217
|
+
|
|
218
|
+
What an assignment of such an expression binds is the thing the expression
|
|
219
|
+
names, so a call of the new name is a call of that thing (#90). Anything
|
|
220
|
+
longer (`a.b.c`, `f().x`, a subscript) says too little and gives None.
|
|
221
|
+
"""
|
|
222
|
+
node = _unparenthesized(node)
|
|
223
|
+
if node is None:
|
|
224
|
+
return None
|
|
225
|
+
if node.type == "identifier":
|
|
226
|
+
return ("name", _text(node))
|
|
227
|
+
if node.type == "attribute":
|
|
228
|
+
obj, attr = node.child_by_field_name("object"), node.child_by_field_name("attribute")
|
|
229
|
+
if obj is not None and obj.type == "identifier" and attr is not None:
|
|
230
|
+
return ("attr", f"{_text(obj)}.{_text(attr)}")
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
214
234
|
def _value_binder(value: Any, scope: PyScope, handed_over: bool = False) -> Binder:
|
|
215
235
|
"""The binder of a name one statement binds to `value`, evaluated in `scope`.
|
|
216
236
|
|
|
@@ -219,7 +239,8 @@ def _value_binder(value: Any, scope: PyScope, handed_over: bool = False) -> Bind
|
|
|
219
239
|
"""
|
|
220
240
|
origin = _value_origin(value)
|
|
221
241
|
if origin is None:
|
|
222
|
-
|
|
242
|
+
alias = None if handed_over else _alias_origin(value)
|
|
243
|
+
return _VALUE if alias is None else Binder("value", origin=(alias[0], alias[1], scope))
|
|
223
244
|
how, name = origin
|
|
224
245
|
return Binder("value", origin=("with" if handed_over and how == "call" else how, name, scope))
|
|
225
246
|
|
|
@@ -599,20 +620,44 @@ def resolving_scope(scope: PyScope, name: str) -> PyScope | None:
|
|
|
599
620
|
return None
|
|
600
621
|
|
|
601
622
|
|
|
602
|
-
def resolve(scope: PyScope, name: str) -> Resolution:
|
|
623
|
+
def resolve(scope: PyScope, name: str, _following: frozenset[int] = frozenset()) -> Resolution:
|
|
603
624
|
"""What a bare `name` denotes when called from `scope`."""
|
|
604
625
|
home = resolving_scope(scope, name)
|
|
605
|
-
return Resolution("none") if home is None else _outcome(home.binders[name])
|
|
626
|
+
return Resolution("none") if home is None else _outcome(home.binders[name], _following)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _aliased(binder: Binder, origin: tuple[str, str, PyScope], following: frozenset[int]) -> Resolution:
|
|
630
|
+
"""What the one assignment `name = <name or module.attr>` makes the name denote (#90).
|
|
631
|
+
|
|
632
|
+
A definition or an imported symbol stays what it is. `module.attr` is
|
|
633
|
+
"member": the attribute of an in-repo module, bound as the call
|
|
634
|
+
`module.attr()` is. Anything else, and a name that leads back to itself, is
|
|
635
|
+
a value as before.
|
|
636
|
+
"""
|
|
637
|
+
how, text, scope = origin
|
|
638
|
+
if id(binder) in following:
|
|
639
|
+
return Resolution("value")
|
|
640
|
+
following = following | {id(binder)}
|
|
641
|
+
if how == "name":
|
|
642
|
+
found = resolve(scope, text, following)
|
|
643
|
+
return found if found.kind in ("def", "symbol", "member") else Resolution("value")
|
|
644
|
+
module_name, attr = text.split(".", 1)
|
|
645
|
+
root = resolve(scope, module_name, following)
|
|
646
|
+
if root.kind == "alias" and root.target:
|
|
647
|
+
return Resolution("member", qualname=attr, target=root.target)
|
|
648
|
+
return Resolution("value")
|
|
606
649
|
|
|
607
650
|
|
|
608
|
-
def _outcome(binders: list[Binder]) -> Resolution:
|
|
651
|
+
def _outcome(binders: list[Binder], following: frozenset[int] = frozenset()) -> Resolution:
|
|
609
652
|
"""§4.2's outcome table, for the binders of one name in its resolving scope."""
|
|
610
|
-
distinct: dict[tuple[str | None, ...], Binder] = {}
|
|
653
|
+
distinct: dict[tuple[str | int | None, ...], Binder] = {}
|
|
611
654
|
for binder in binders:
|
|
612
655
|
if binder.kind in ("def", "class"):
|
|
613
|
-
key: tuple[str | None, ...] = ("def", binder.qualname)
|
|
656
|
+
key: tuple[str | int | None, ...] = ("def", binder.qualname)
|
|
614
657
|
elif binder.kind == "import":
|
|
615
658
|
key = ("import", binder.import_kind, binder.target)
|
|
659
|
+
elif binder.origin is not None and binder.origin[0] in ("name", "attr"):
|
|
660
|
+
key = ("alias", binder.origin[0], binder.origin[1], id(binder.origin[2]))
|
|
616
661
|
else:
|
|
617
662
|
key = ("value",)
|
|
618
663
|
distinct.setdefault(key, binder)
|
|
@@ -622,6 +667,8 @@ def _outcome(binders: list[Binder]) -> Resolution:
|
|
|
622
667
|
return Resolution("def", qualname=binder.qualname, module_level=binder.module_level)
|
|
623
668
|
if binder.kind == "import" and binder.import_kind is not None and binder.import_kind != "unknown":
|
|
624
669
|
return Resolution(binder.import_kind, target=binder.target)
|
|
670
|
+
if binder.origin is not None and binder.origin[0] in ("name", "attr"):
|
|
671
|
+
return _aliased(binder, binder.origin, following)
|
|
625
672
|
return Resolution("value")
|
|
626
673
|
# Unresolved imports of one name (`try: import ujson as json` /
|
|
627
674
|
# `except ImportError: import json`) leave the repo whichever one runs. So
|
|
@@ -643,6 +690,9 @@ def _origin_receiver(how: str, name: str, scope: PyScope) -> Receiver | None:
|
|
|
643
690
|
"""What a value of this origin is, with the name it calls resolved in `scope`."""
|
|
644
691
|
if how == "literal":
|
|
645
692
|
return _BUILTIN_RECEIVER
|
|
693
|
+
if how in ("name", "attr"):
|
|
694
|
+
# An alias (#90) is the thing it names, not what that thing returns.
|
|
695
|
+
return None
|
|
646
696
|
found = resolve(scope, name)
|
|
647
697
|
if found.kind == "none":
|
|
648
698
|
# Bound nowhere in the file, so the name is the builtin's own -- unless
|
graphite/hookinstall.py
CHANGED
|
@@ -159,6 +159,23 @@ def install_hooks(root: Path, interpreter: Path) -> list[str]:
|
|
|
159
159
|
return relocated
|
|
160
160
|
|
|
161
161
|
|
|
162
|
+
def chain_slots(hdir: Path) -> dict[str, dict[str, str | bool]]:
|
|
163
|
+
"""Where another tool's hook for each trigger goes, and whether one is there.
|
|
164
|
+
|
|
165
|
+
Each trampoline runs `<hook>.local` beside it first when that file exists,
|
|
166
|
+
so that is where another tool's hook for the same event belongs, in either
|
|
167
|
+
install order. The name is part of the hook contract (docs/compatibility.md);
|
|
168
|
+
`doctor --json` and `init` report it so another tool need not read it out of
|
|
169
|
+
the trampoline's shell (channel rounds 323, 324). It is named relative to
|
|
170
|
+
the hooks directory, which `core.hooksPath` may put outside the repository,
|
|
171
|
+
so the report carries no path. `present` is the trampoline's own `[ -f ]`.
|
|
172
|
+
"""
|
|
173
|
+
return {
|
|
174
|
+
hook: {"file": f"{hook}{CHAINED_SUFFIX}", "present": (hdir / f"{hook}{CHAINED_SUFFIX}").is_file()}
|
|
175
|
+
for hook in TRIGGERS
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
162
179
|
def uninstall_hooks(root: Path) -> list[str]:
|
|
163
180
|
"""Remove graphite's trampolines, restoring anything they chained.
|
|
164
181
|
|
graphite/init.py
CHANGED
|
@@ -397,6 +397,7 @@ def init_project(
|
|
|
397
397
|
"path": str(hookinstall.hooks_dir(root)),
|
|
398
398
|
"action": "installed",
|
|
399
399
|
"relocated": relocated,
|
|
400
|
+
"chain_slots": hookinstall.chain_slots(hookinstall.hooks_dir(root)),
|
|
400
401
|
}
|
|
401
402
|
elif install_hooks:
|
|
402
403
|
# Not a git repo (yet). `hookinstall.install_hooks` would still write
|
graphite/resolve.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"""Source resolution helpers for Graphite extraction."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import ast
|
|
4
5
|
import hashlib
|
|
5
6
|
import json
|
|
6
7
|
import posixpath
|
|
7
8
|
import tomllib
|
|
8
|
-
from dataclasses import dataclass
|
|
9
|
+
from dataclasses import dataclass, field
|
|
9
10
|
from functools import lru_cache
|
|
10
11
|
from pathlib import Path, PurePosixPath
|
|
11
12
|
from typing import TYPE_CHECKING, Final, Iterable
|
|
@@ -31,6 +32,94 @@ def _rel_path_set_digest(rel_paths: frozenset[str]) -> str:
|
|
|
31
32
|
digest.update(b"\0")
|
|
32
33
|
return digest.hexdigest()
|
|
33
34
|
|
|
35
|
+
#: How many re-exports one name is followed through. A real package chain is a
|
|
36
|
+
#: hop or two; the bound only has to be finite beside the cycle guard.
|
|
37
|
+
_MAX_REEXPORT_HOPS: Final = 8
|
|
38
|
+
|
|
39
|
+
#: `(package __init__ path, name it binds) -> (module, leading dots, name there)`
|
|
40
|
+
ReexportTable = dict[tuple[str, str], tuple[str, int, str]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _module_level_bindings(body: list[ast.stmt]) -> dict[str, list[ast.stmt]]:
|
|
44
|
+
"""Every statement that binds each name at module level, nested ones included.
|
|
45
|
+
|
|
46
|
+
A name bound under `if` or `try` counts as bound there, and so does a
|
|
47
|
+
name an `import` or `def` binds: the statement list is what lets the caller
|
|
48
|
+
tell a name bound exactly once, by one `from X import name`, from a name
|
|
49
|
+
the package binds some other way too.
|
|
50
|
+
"""
|
|
51
|
+
bound: dict[str, list[ast.stmt]] = {}
|
|
52
|
+
|
|
53
|
+
def add(name: str, stmt: ast.stmt) -> None:
|
|
54
|
+
bound.setdefault(name, []).append(stmt)
|
|
55
|
+
|
|
56
|
+
def visit(stmts: list[ast.stmt]) -> None:
|
|
57
|
+
for stmt in stmts:
|
|
58
|
+
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
59
|
+
add(stmt.name, stmt)
|
|
60
|
+
elif isinstance(stmt, ast.Import):
|
|
61
|
+
for alias in stmt.names:
|
|
62
|
+
add((alias.asname or alias.name).split(".", 1)[0], stmt)
|
|
63
|
+
elif isinstance(stmt, ast.ImportFrom):
|
|
64
|
+
for alias in stmt.names:
|
|
65
|
+
if alias.name != "*":
|
|
66
|
+
add(alias.asname or alias.name, stmt)
|
|
67
|
+
else:
|
|
68
|
+
for node in ast.walk(stmt):
|
|
69
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
70
|
+
add(node.id, stmt)
|
|
71
|
+
elif isinstance(node, ast.ExceptHandler) and node.name:
|
|
72
|
+
add(node.name, stmt)
|
|
73
|
+
elif isinstance(node, ast.alias):
|
|
74
|
+
add((node.asname or node.name).split(".", 1)[0], stmt)
|
|
75
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
76
|
+
add(node.name, stmt)
|
|
77
|
+
|
|
78
|
+
visit(body)
|
|
79
|
+
return bound
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _package_reexports(rel_path: str, source: bytes) -> ReexportTable:
|
|
83
|
+
"""The names one `__init__.py` binds only by a top-level `from X import name`.
|
|
84
|
+
|
|
85
|
+
A name the file binds any other way too (assigned, defined, imported a
|
|
86
|
+
second time, imported under a condition) is left out, and so is a file that
|
|
87
|
+
does not parse: `from pkg import name` then stays bound to the package, as
|
|
88
|
+
it was before re-exports were followed.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
tree = ast.parse(source)
|
|
92
|
+
except (SyntaxError, ValueError, RecursionError):
|
|
93
|
+
return {}
|
|
94
|
+
bound = _module_level_bindings(tree.body)
|
|
95
|
+
table: ReexportTable = {}
|
|
96
|
+
for stmt in tree.body:
|
|
97
|
+
if not isinstance(stmt, ast.ImportFrom) or not stmt.module:
|
|
98
|
+
# `from . import sub` binds a submodule, which has its own path.
|
|
99
|
+
continue
|
|
100
|
+
for alias in stmt.names:
|
|
101
|
+
local = alias.asname or alias.name
|
|
102
|
+
if alias.name == "*" or len(bound.get(local, ())) != 1:
|
|
103
|
+
continue
|
|
104
|
+
table[(rel_path, local)] = (stmt.module, stmt.level, alias.name)
|
|
105
|
+
return table
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _python_reexports(entries: Iterable[FileEntry]) -> ReexportTable:
|
|
109
|
+
"""The re-export table of every package `__init__.py` among the entries."""
|
|
110
|
+
table: ReexportTable = {}
|
|
111
|
+
for entry in entries:
|
|
112
|
+
rel_path = PurePosixPath(entry.rel_path).as_posix()
|
|
113
|
+
if PurePosixPath(rel_path).name != "__init__.py":
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
source = Path(entry.abs_path).read_bytes()
|
|
117
|
+
except OSError:
|
|
118
|
+
continue
|
|
119
|
+
table.update(_package_reexports(rel_path, source))
|
|
120
|
+
return table
|
|
121
|
+
|
|
122
|
+
|
|
34
123
|
_TS_EXTENSIONS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
|
|
35
124
|
_INDEX_NAMES = tuple(f"index{ext}" for ext in _TS_EXTENSIONS)
|
|
36
125
|
_BUILTIN_OBJECTS = {
|
|
@@ -92,6 +181,14 @@ class SourceIndex:
|
|
|
92
181
|
# (crate dir, declared idents) per manifest -- the authoritative answer to
|
|
93
182
|
# "did THIS file's crate declare that dependency". See issue #38.
|
|
94
183
|
cargo_crate_dependencies: tuple[tuple[str, frozenset[str]], ...] = ()
|
|
184
|
+
# What each package `__init__.py` re-exports by a plain `from X import name`
|
|
185
|
+
# (#87). Read from the files themselves, so extraction of an importer that
|
|
186
|
+
# did not change depends on it: see `python_reexport_digest`.
|
|
187
|
+
python_reexports: ReexportTable = field(default_factory=dict, compare=False, repr=False)
|
|
188
|
+
# rel path -> file node id, for the paths of the tree whose plain id another
|
|
189
|
+
# path has too (#82). Empty for a tree without such a pair. Set by
|
|
190
|
+
# `extract_all`, which owns how a file node id is made.
|
|
191
|
+
file_ids: dict[str, str] = field(default_factory=dict, compare=False, repr=False)
|
|
95
192
|
|
|
96
193
|
@classmethod
|
|
97
194
|
def from_entries(cls, entries: Iterable[FileEntry], cfg: Config | None = None) -> "SourceIndex":
|
|
@@ -116,8 +213,42 @@ class SourceIndex:
|
|
|
116
213
|
cargo_crates=_load_cargo_crates(root, frozenset(rel_paths)),
|
|
117
214
|
cargo_dependencies=_load_cargo_dependencies(root, frozenset(rel_paths)),
|
|
118
215
|
cargo_crate_dependencies=_load_cargo_crate_dependencies(root, frozenset(rel_paths)),
|
|
216
|
+
python_reexports=_python_reexports(entries),
|
|
119
217
|
)
|
|
120
218
|
|
|
219
|
+
def python_reexport_digest(self) -> str:
|
|
220
|
+
"""Digest of the re-export table, for the cache key of a Python file.
|
|
221
|
+
|
|
222
|
+
An importer binds `from pkg import name` to whatever `pkg/__init__.py`
|
|
223
|
+
re-exports, and its bytes do not change when that does.
|
|
224
|
+
"""
|
|
225
|
+
digest = hashlib.blake2b(digest_size=8)
|
|
226
|
+
for (rel_path, name), (module, level, original) in sorted(self.python_reexports.items()):
|
|
227
|
+
digest.update(f"{rel_path}\0{name}\0{level}\0{module}\0{original}\0".encode("utf-8", errors="replace"))
|
|
228
|
+
return digest.hexdigest()
|
|
229
|
+
|
|
230
|
+
def follow_python_reexport(self, rel_path: str, name: str) -> tuple[str, str]:
|
|
231
|
+
"""The file and name that define `name` as `rel_path` exports it.
|
|
232
|
+
|
|
233
|
+
Follows `from X import name` statements the package `__init__.py`
|
|
234
|
+
makes, for as many steps as the source states. It stops where a hop does
|
|
235
|
+
not resolve to a file of the tree, where a name is bound some other way,
|
|
236
|
+
at a cycle, and at `_MAX_REEXPORT_HOPS`; each returns the last place
|
|
237
|
+
reached, which is `(rel_path, name)` itself when nothing is followed.
|
|
238
|
+
"""
|
|
239
|
+
seen = {(rel_path, name)}
|
|
240
|
+
for _ in range(_MAX_REEXPORT_HOPS):
|
|
241
|
+
hop = self.python_reexports.get((rel_path, name))
|
|
242
|
+
if hop is None:
|
|
243
|
+
break
|
|
244
|
+
module, level, original = hop
|
|
245
|
+
target = self.resolve_python_module(rel_path, module, level)
|
|
246
|
+
if target is None or (target, original) in seen:
|
|
247
|
+
break
|
|
248
|
+
seen.add((target, original))
|
|
249
|
+
rel_path, name = target, original
|
|
250
|
+
return rel_path, name
|
|
251
|
+
|
|
121
252
|
def file_set_digest(self) -> str:
|
|
122
253
|
"""Stable digest of the repo's file set.
|
|
123
254
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: graphite-code
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Local-first, zero-LLM knowledge graph extraction for codebases.
|
|
5
5
|
Project-URL: Homepage, https://github.com/jared0565/graphite
|
|
6
6
|
Project-URL: Repository, https://github.com/jared0565/graphite
|
|
@@ -48,7 +48,7 @@ Description-Content-Type: text/markdown
|
|
|
48
48
|
|
|
49
49
|
Local-first, deterministic knowledge graph extraction for codebases — zero-LLM, daemon-maintained, agent-agnostic.
|
|
50
50
|
|
|
51
|
-
**Status: 1.
|
|
51
|
+
**Status: 1.4.0, production/stable.** Published on PyPI as
|
|
52
52
|
[`graphite-code`](https://pypi.org/project/graphite-code/) with PEP 740
|
|
53
53
|
attestations; supported on Windows, Linux and macOS with Python 3.11–3.14
|
|
54
54
|
(every cell gates CI). What 1.x promises — CLI, JSON outputs, `graph.json`,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
graphite/__init__.py,sha256=
|
|
1
|
+
graphite/__init__.py,sha256=GC0juqOmVzk_zpoOBLn2WPHFXWWoxCfPb4CvSCEqyCs,2181
|
|
2
2
|
graphite/__main__.py,sha256=Bp5bcXE6M0AnuNW1LjOXOEvGG5r0fXZoeRb_k5Yxphs,149
|
|
3
3
|
graphite/_cleanup_worker.py,sha256=oIXOPa1jHVP8ePRSwn27nZEHOAc4DSBrjeH2Ds9wuq8,19619
|
|
4
4
|
graphite/_win32_ctypes.py,sha256=YLH4kpKjL7zUUOs9E8C-Kjdy45PJBd6eRUeKwiKdSV4,1476
|
|
5
5
|
graphite/activation.py,sha256=Jcgk8AFvqbMNQiPAa2MTzSO0BAYRC5FX6MsZUjbDe1I,5249
|
|
6
|
-
graphite/agent_hooks.py,sha256=
|
|
6
|
+
graphite/agent_hooks.py,sha256=rIeA6ofRLDMSdf4MfFMUpnXHVcHHzht6NZpfgSOgZd4,64248
|
|
7
7
|
graphite/agent_settings.py,sha256=Ec95ilPhkPOM3vW67pdsjYivnCyc5uCqWfCBu-DLI3A,11085
|
|
8
8
|
graphite/analyze.py,sha256=BwiZ4lkQ8Oo8WvnIGLI03YSNUpynYvV46tVbzHH5iWQ,9304
|
|
9
9
|
graphite/answer_contract.py,sha256=KTnGmtT6gvi28-PhLZp9vaj7FDvGrqLW_BvhAa-VPR4,31237
|
|
@@ -11,7 +11,7 @@ graphite/bootstrap.py,sha256=OPDzN9jSr8IE1N7xOnVKk3FgO2UqBTOUNcNV-pZAsus,8448
|
|
|
11
11
|
graphite/buildlock.py,sha256=QXo4cnGGCmhxuuYdy6f5OHF82Yg6zHUMxLVdxwG49ic,5393
|
|
12
12
|
graphite/cache.py,sha256=Mh28dYg9irXBvH-oa8jpFSM-ehXcs5FuNVHVzF_WRA8,9143
|
|
13
13
|
graphite/channel.py,sha256=UNFAGuws6XFbkBu0fnVLepBDxT1BWAJlW33IPVlDi9M,72908
|
|
14
|
-
graphite/cli.py,sha256=
|
|
14
|
+
graphite/cli.py,sha256=JYypUmsaV3c3VILwI7ay0wd_D2m5PfAWStu55w0nMNg,161757
|
|
15
15
|
graphite/cluster.py,sha256=j5dFT40DD7scD4h8d7XJoAGQIcJKpsKsrFceiJkOuzw,4066
|
|
16
16
|
graphite/config.py,sha256=ZWhWOWYEyKGoHCp6vWI4M4NQM57ohf5BaX0Ty8HfWYM,9267
|
|
17
17
|
graphite/context.py,sha256=vWMuFxqDGby-9Gs_t9XNJzKR-nZqwMdbU2y6N_GypWU,13215
|
|
@@ -21,7 +21,7 @@ graphite/daemon_launch.py,sha256=d6VhJVARdhBSdGWPHxKgveDisX5JWHlDJeV6tjLJWKw,232
|
|
|
21
21
|
graphite/debt.py,sha256=mvxoFrICwbWP19YKlf1cZIPFg8qIf7Jgyr9kIweVBbE,4664
|
|
22
22
|
graphite/dependency_install.py,sha256=hcwRmg74BfweXfRzzgRqHhLGEEIWokNLebJCxM2D80k,58600
|
|
23
23
|
graphite/detach.py,sha256=BlUjeh_UcjpQ9DMRZFOgxhk83zUbMSTPmXe6t1FFEKE,1270
|
|
24
|
-
graphite/doctor.py,sha256=
|
|
24
|
+
graphite/doctor.py,sha256=4ZocGDXNFfAAeWempEjIe2-WwxROKygM04P3FfLC1qw,34733
|
|
25
25
|
graphite/doctor_probes.py,sha256=pSU62zuHv3pxcXZWquUUvd6UVur-grfrr3CJ0PM8uis,89852
|
|
26
26
|
graphite/engine_identity.py,sha256=hZYA9fBsg2fj_61Rtkc84XzJrdsa2MaDFOLZJV4I7eE,8951
|
|
27
27
|
graphite/freshness.py,sha256=kkhs-ArInlv1XxedmQRAFiThvrHt11mN5UAGCQCU4A8,5284
|
|
@@ -30,11 +30,11 @@ graphite/graph.py,sha256=2mhbIrx4ekidgXnw3gLWDtGcVdvOmYfpZ4oATtwbao0,4284
|
|
|
30
30
|
graphite/graph_io.py,sha256=rWjc3_Lq7DOg1_XuM4eY8GH9GwIHowo0CcABTIRtLyc,6563
|
|
31
31
|
graphite/health.py,sha256=Sh8P6_RvEBaFeICUbhncqw5I-Nlge9ASTkQpxSUtKfs,5525
|
|
32
32
|
graphite/hook_entry.py,sha256=0lfCZB9-B_9QBKagLhV_VIMVFcrOeAwpLcFXUjhMMpg,2655
|
|
33
|
-
graphite/hookinstall.py,sha256=
|
|
33
|
+
graphite/hookinstall.py,sha256=7qfboCBjP7DAeugbXXjZDDg-0aP7GJc_WhqxgJsOlV4,10215
|
|
34
34
|
graphite/hookshim.py,sha256=4to9xkNOWPjbk1Es97KTTknjJM9gdHkbEDjCQq8ntec,3771
|
|
35
35
|
graphite/incident_ledger.py,sha256=wHW-P_PJNollM_L9bAXHaxFeLb89wMXs7XX0VrH7aSw,8654
|
|
36
36
|
graphite/ingest.py,sha256=0uzPN7fyokVOL622TTXd7COpd5CpViOrsRYDm53Q1Qw,11952
|
|
37
|
-
graphite/init.py,sha256=
|
|
37
|
+
graphite/init.py,sha256=yoCyQ83UJ7Nb_suq_HUAVOwPhHbqpWfKtfD4P3n_LPE,41322
|
|
38
38
|
graphite/io.py,sha256=YBXfMcPsv1Lzun8ByW4BQlfgMm54akckLFrUhwWtleI,2996
|
|
39
39
|
graphite/launchd_agent.py,sha256=rmKhhCbB-9FDU_3A1b2OlZxKpxZqdgtYnLbzkFMs_rI,4529
|
|
40
40
|
graphite/listing.py,sha256=fxKBK7_1q9IcFK_VAhpQCKyDV7q_RajJ0r8O2h9rqes,1666
|
|
@@ -54,7 +54,7 @@ graphite/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
54
54
|
graphite/query.py,sha256=qOObUOJGtHTf_MWrxNM1ykfl7TR2aqZ7MBvjib4i01Y,29659
|
|
55
55
|
graphite/query_plan.py,sha256=TLGwOnsL6Ia1hCsDzmWWBwtcn37Dn6aOuAvGowtgSXg,3768
|
|
56
56
|
graphite/replacement_audit.py,sha256=AR97eZ0h8Rx5VWOW2rJSqDGwEVwEDj08kcSiepyidaE,12393
|
|
57
|
-
graphite/resolve.py,sha256
|
|
57
|
+
graphite/resolve.py,sha256=FMaOCWbCGD7aEpN5WOmdLs5Y35nGdw_G8SUuHtxqjV0,34778
|
|
58
58
|
graphite/review.py,sha256=Dd35aA8n5R1Osoop769uymdOFFZMB4BFexAKJD9bO7o,27810
|
|
59
59
|
graphite/savings.py,sha256=W5FbXScpteMTwngVUtDqPct99tHY7W8s3XlT4hbPJCY,3175
|
|
60
60
|
graphite/systemd_unit.py,sha256=oPfdHwFJpfW1RnQeIkyVnOYvDhSRjll57424whQYWg4,4503
|
|
@@ -71,8 +71,8 @@ graphite/export/html.py,sha256=WB-9p0fpjFh-bTpHl5aYg1UdM_PGXwaZEtyR3lleNZU,8949
|
|
|
71
71
|
graphite/export/json.py,sha256=oUrR1aXE5eiRDrj1_oXIl6qOPsTiqP3rmGtLfHF_nJc,1267
|
|
72
72
|
graphite/export/md.py,sha256=JqxULAZHdsxBsbgeZVzZ08C0_ZmQ9Tsln_dAmvHmbjY,2557
|
|
73
73
|
graphite/extract/__init__.py,sha256=HsF3yU1VcT3EsvUfxMJ0iVW2Ud_XrivnBGmdi9QTK3M,118
|
|
74
|
-
graphite/extract/ast.py,sha256=
|
|
75
|
-
graphite/extract/pyscope.py,sha256=
|
|
74
|
+
graphite/extract/ast.py,sha256=l5M6m768RtxWBj8eHMMLY8sLV4ChABAWZbybGRtHOIA,144260
|
|
75
|
+
graphite/extract/pyscope.py,sha256=bLsH5vJI10nLM12lGEeyvI3uG5uKXX0Jq4wl5TjfJc4,36460
|
|
76
76
|
graphite/routing/__init__.py,sha256=QlzeC4TbkzJNPn6vVrMH36FmpHrH0ZoJzjK_ckF-Z54,177
|
|
77
77
|
graphite/routing/approval.py,sha256=9roEO0d5hBhg3VgUaN8cw4wxzgJn6oKzhO7eQQ9cShQ,13872
|
|
78
78
|
graphite/routing/classifier.py,sha256=9JcVhxWnt9K_xMJkS-xiLUKYHi3LZiTGefkqBUSvvKc,6836
|
|
@@ -113,8 +113,8 @@ graphite/routing/zai_edit.py,sha256=Kra2tI9GkntQAhV1vNHlFlp4sY-ksq2-bY4N9fEfLEc,
|
|
|
113
113
|
graphite/routing/zai_executor.py,sha256=cHUl7DbWRUhYg9Lwm3OiP6H3dI70y_b8iAWCyfgOPi0,6636
|
|
114
114
|
graphite/routing/zai_probe.py,sha256=HL2vM8s2J4-n8Bp3l7QLpWLEk-PhZrt_rLc2cY65hvU,4771
|
|
115
115
|
graphite/ts_resolver.mjs,sha256=wiKDWkJIC9__3wL49ty4W8hRc5biisNNtFhH1aFA47A,10822
|
|
116
|
-
graphite_code-1.
|
|
117
|
-
graphite_code-1.
|
|
118
|
-
graphite_code-1.
|
|
119
|
-
graphite_code-1.
|
|
120
|
-
graphite_code-1.
|
|
116
|
+
graphite_code-1.4.0.dist-info/METADATA,sha256=5UbiMIRD_tve4MYmsHClCpKWc7IfospT5PDnSsxTu7Q,48476
|
|
117
|
+
graphite_code-1.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
118
|
+
graphite_code-1.4.0.dist-info/entry_points.txt,sha256=-BpYAYFyn58DYi-hrFW9hMz6mTwNsYI44s9gaef4MiM,87
|
|
119
|
+
graphite_code-1.4.0.dist-info/licenses/LICENSE,sha256=mfotJ1Lk0VVUgoEPOIR7-x5cvYiz2UkuVH7e3fhoDiY,1066
|
|
120
|
+
graphite_code-1.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|