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 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.3.0"
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 _scan(args: argparse.Namespace, cfg: Config) -> tuple[dict[str, Any], list[Any]]:
259
- cfg = cfg.canonical_graph()
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 underscore,
218
- which is most of `tests/`. Measured occurrences in this repo: zero.
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 (node.start_point[0] + 1) if node.start_point else 1
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 = (node.start_point[1] + 1) if node.start_point else 1
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
- out.append((local, "symbol", _make_id(_file_node_id(parent), original)))
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 (node.start_point[0] + 1) if node.start_point else 1
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
- result.edges.append(_edge(did, _make_id(base_name), "inherits", rel_path, _line(arg)))
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
- if bare and bare not in _LANGUAGE_BUILTIN_GLOBALS:
1632
- resolution = resolve(scope, bare)
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 (node.start_point[0] + 1) if node.start_point else 1
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 (node.start_point[0] + 1) if node.start_point else 1
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"]: _file_node_id(n["source_file"]) for n in nodes if n.get("source_file")
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 = _file_node_id(e["source_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 = {
@@ -112,7 +112,8 @@ class PyScope:
112
112
  class Resolution:
113
113
  """What a bare name denotes at one call site."""
114
114
 
115
- kind: str # "def" | "symbol" | "alias" | "external" | "ambiguous-import" | "value" | "none"
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
- return _VALUE
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.0
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.3.0, production/stable.** Published on PyPI as
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=sJRU6XXgmArJ1LdtGCTQ_XsPXmii2SK7NZwWLylO6Fo,2181
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=pvnX224z8_742q8sOYUl0EL1855BoSR4Okd-xZj9gmE,61298
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=O0QjnQQ0Q5gZQwbxd96gUqj4GbcrVcPOI2b_twRPGNo,160778
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=SH5emKn0P78AU-6Us3atr4GvV1UZLZpo91W8OBkpJlM,34686
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=1fCjMeQsKVfOmrJm42p94SKL14hZ31R-WANZPZzkzmM,9344
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=TD7996Wp51-anUi1KjTyWyeMlXyR14BWnMDzK3uSZ0Y,41241
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=-73GujPNFx12DKUTPknq3OpRaOkWe-dLbt-RrlZelCI,28710
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=uJi7RyZOZJtKmHZaNk98qqoJ_irke7rUXcipHslfjrc,139062
75
- graphite/extract/pyscope.py,sha256=QnyuKq4hnF4BIxAnxTYZ2aDgx-BtnzCnGYwWBcwNmbs,33878
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.3.0.dist-info/METADATA,sha256=RGAU6kW0kTmOVDAnYHGJmsR2vLKXztPYF_KlvL8DaSI,48476
117
- graphite_code-1.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
118
- graphite_code-1.3.0.dist-info/entry_points.txt,sha256=-BpYAYFyn58DYi-hrFW9hMz6mTwNsYI44s9gaef4MiM,87
119
- graphite_code-1.3.0.dist-info/licenses/LICENSE,sha256=mfotJ1Lk0VVUgoEPOIR7-x5cvYiz2UkuVH7e3fhoDiY,1066
120
- graphite_code-1.3.0.dist-info/RECORD,,
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,,