ph-code-graph 0.2.0__py3-none-any.whl → 0.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.
ph_code_graph/__init__.py CHANGED
@@ -42,7 +42,9 @@ answers most of what an agent actually asks and reports where it cannot.
42
42
  from __future__ import annotations
43
43
 
44
44
  import hashlib
45
+ import importlib.util
45
46
  import logging
47
+ from collections.abc import Mapping
46
48
  from dataclasses import dataclass, field
47
49
  from pathlib import Path
48
50
  from typing import Any, Literal
@@ -59,6 +61,7 @@ from ph.seams._registry import contribute_item
59
61
  from ph.seams.changes import tree_state
60
62
  from ph.seams.commands import CommandContext, CommandDefinition
61
63
  from ph.seams.diagnostics import Diagnostic, contribute
64
+ from ph.seams.fs import FsService
62
65
  from ph.seams.skills import discover_skills
63
66
  from ph.text import count_of
64
67
  from ph.tools.definition import ToolModel, ToolOutput, ToolRunContext, define_tool, text_content
@@ -196,7 +199,8 @@ class CodeGraphSeam:
196
199
 
197
200
  def report(self) -> list[tuple[str, str]]:
198
201
  """`phern doctor`'s section."""
199
- import tree_sitter
202
+ # Not a declared dependency: the row checks for it before this runs.
203
+ import tree_sitter # noqa: PLC0415
200
204
 
201
205
  # No `hasattr` guard: the row declares `inject=[FS]`, so `ctx.fs` is
202
206
  # present for as long as it is active — and a `Path.cwd()` fallback
@@ -492,8 +496,6 @@ def offer_skills(ctx: Context) -> None:
492
496
  @plugin("code-graph", inject=[TOOLS, FS], config=Config)
493
497
  async def apply(ctx: Context, config: Config) -> None:
494
498
  """Mount the seam and register both tools."""
495
- import importlib.util
496
-
497
499
  for module in ("tree_sitter", "tree_sitter_language_pack"):
498
500
  if importlib.util.find_spec(module) is None:
499
501
  raise MountRefusal(MISSING)
@@ -535,6 +537,16 @@ async def apply(ctx: Context, config: Config) -> None:
535
537
  def store(run: ToolRunContext) -> CodeGraphStore:
536
538
  return seam.store_for(ctx.require(FS).root_for(run.agent))
537
539
 
540
+ def _forget_missing(
541
+ fs: FsService,
542
+ book: CodeGraphStore,
543
+ known: Mapping[str, tuple[str, str]],
544
+ run: ToolRunContext,
545
+ ) -> int:
546
+ """Drop every indexed path that is no longer on disk. One thread hop."""
547
+ gone = [path for path in known if not fs.resolve(path, agent=run.agent).exists()]
548
+ return book.forget(gone) if gone else 0
549
+
538
550
  async def index_tool(args: IndexArgs, run: ToolRunContext) -> dict[str, Any]:
539
551
  fs = ctx.require(FS)
540
552
  book = store(run)
@@ -616,10 +628,24 @@ async def apply(ctx: Context, config: Config) -> None:
616
628
  slice_ = await fs.read(
617
629
  path,
618
630
  limit=None,
631
+ # `skip_reason` above is this walk's bound, and it is a skip
632
+ # rather than a raise on purpose: an indexing pass must not
633
+ # fail because it found a minified bundle. `read`'s own
634
+ # default would be a second, larger bound that raises.
635
+ max_bytes=None,
619
636
  scope=run.scope,
620
637
  agent=run.agent,
621
638
  session=run.session,
622
639
  )
640
+ # **No prose test here, deliberately** (X6 review). The text
641
+ # index refuses documents that hold no prose; source code is not
642
+ # prose, and the same predicate refused a module whose only
643
+ # statement is a long `__all__`, and a `TABLE = {...}` literal
644
+ # beside `def lookup()` — so `lookup` would have vanished from
645
+ # this index with nothing but a skip line to say so. Telling a
646
+ # minified bundle from a legitimate data module by shape is not
647
+ # reliable for code, and a false positive here loses symbols
648
+ # silently. `skip_reason`'s size bound stays this walk's guard.
623
649
  # The content hash stays the authority on *whether* a file
624
650
  # changed — the filter above only decides whether to open it, so
625
651
  # the "content, not clock" guarantee is untouched.
@@ -642,6 +668,25 @@ async def apply(ctx: Context, config: Config) -> None:
642
668
  book.put, path, digest, extraction, state.id_for(path)
643
669
  )
644
670
  indexed += 1
671
+ if not args.forget:
672
+ # **Gone from disk is gone from the index** (X3). A deleted file
673
+ # kept its symbols until somebody ran `forget` by hand, so
674
+ # `code_graph` went on naming definitions at paths that are not
675
+ # there — and a rename showed the symbol twice, once under each
676
+ # name, with nothing saying which was real. A pointer into a file
677
+ # that does not exist is the one answer this tool must not give,
678
+ # because the model's next move is to open it.
679
+ #
680
+ # **Asked of the filesystem, not of the walk.** "Absent from
681
+ # `paths`" looks like the same question and is not: `collect`
682
+ # stops at `max_files` and reports nothing, and the policy screen
683
+ # prunes whole directories — so on a tree past the cap, or with a
684
+ # `permissions-fs` rule hiding a subtree, the walk is a *subset*
685
+ # of the scope and differencing against it would forget
686
+ # everything it did not reach. It also settles the narrowed walk
687
+ # for free, where the question is not "did we cover the scope"
688
+ # but "is this file still there".
689
+ removed = await anyio.to_thread.run_sync(_forget_missing, fs, book, known, run)
645
690
  # Stored last, and only after the loop: a token recorded before the
646
691
  # writes would, on a crash between the two, vouch for files this run
647
692
  # never actually indexed.
ph_code_graph/_extract.py CHANGED
@@ -40,6 +40,7 @@ query alone yields two tags and no calls — measured. `INHERITS` is that fact.
40
40
  from __future__ import annotations
41
41
 
42
42
  import logging
43
+ import os
43
44
  from collections.abc import Callable, Sequence
44
45
  from dataclasses import dataclass, replace
45
46
  from functools import cache
@@ -129,7 +130,8 @@ class Extraction:
129
130
 
130
131
 
131
132
  def _pack() -> Any: # noqa: ANN401
132
- import tree_sitter_language_pack
133
+ # Not a declared dependency: the row checks for it before this runs.
134
+ import tree_sitter_language_pack # noqa: PLC0415
133
135
 
134
136
  return tree_sitter_language_pack
135
137
 
@@ -196,8 +198,6 @@ def use_cache(directory: Path) -> Path:
196
198
  An operator who set the environment variable deliberately keeps it — their
197
199
  spelling wins, which is what makes the variable still mean something.
198
200
  """
199
- import os
200
-
201
201
  global _configured
202
202
 
203
203
  override = os.environ.get("TREE_SITTER_LANGUAGE_PACK_CACHE_DIR")
@@ -343,7 +343,8 @@ def _tags_query(language: str) -> Any: # noqa: ANN401
343
343
  for the same reason: a `Query` is read-only once built, and `extract` makes
344
344
  a fresh `QueryCursor` per call — which is the mutable half.
345
345
  """
346
- from tree_sitter import Query
346
+ # Not a declared dependency: the row checks for it before this runs.
347
+ from tree_sitter import Query # noqa: PLC0415
347
348
 
348
349
  pack = _pack()
349
350
  sources = [pack.get_tags_query(one) for one in INHERITS.get(language, ())]
@@ -359,7 +360,8 @@ def extract(path: str, text: str, language: str) -> Extraction:
359
360
  should stop the batch or be reported and skipped is its decision, not
360
361
  this function's.
361
362
  """
362
- from tree_sitter import QueryCursor
363
+ # Not a declared dependency: the row checks for it before this runs.
364
+ from tree_sitter import QueryCursor # noqa: PLC0415
363
365
 
364
366
  pack = _pack()
365
367
  config = pack.ProcessConfig(
ph_code_graph/_store.py CHANGED
@@ -213,6 +213,22 @@ class Hit:
213
213
  }
214
214
 
215
215
 
216
+ def _fts_values(rowid: int, name: str, doc: str | None, path: str) -> tuple[int, str, str, str]:
217
+ """One `symbols_fts` row, for the insert **and** the delete that undoes it.
218
+
219
+ FTS5's `'delete'` command re-derives the postings to remove from the values
220
+ it is given, so a delete whose tuple differs from the insert's by so much as
221
+ one NULL removes nothing — and SQLite reports no error for it. `_forget`
222
+ carried a copy of this expression with a comment saying it mirrored the
223
+ insert, which is a reader being asked to check two expressions in two
224
+ methods rather than a compiler being asked to.
225
+
226
+ `doc or ""` is the whole substance: `symbols.doc` is nullable and the FTS
227
+ column is not.
228
+ """
229
+ return (rowid, name, doc or "", path)
230
+
231
+
216
232
  @dataclass(slots=True)
217
233
  class CodeGraphStore:
218
234
  """The index. **Blocking**; the seam calls it in a worker thread."""
@@ -361,17 +377,33 @@ class CodeGraphStore:
361
377
  def _forget(self, connection: sqlite3.Connection, paths: Sequence[str]) -> int:
362
378
  gone = 0
363
379
  for path in paths:
364
- # The FTS rows first, by the id they were inserted under: a
380
+ # The FTS rows first, **with the values they were inserted under**: a
365
381
  # contentless table cannot be asked which rows belong to a path, so
366
- # the ids come from `symbols` while it still has them.
367
- ids = [
368
- row["id"]
369
- for row in connection.execute("SELECT id FROM symbols WHERE path = ?", (path,))
382
+ # they come from `symbols` while it still has them (X1).
383
+ #
384
+ # The columns and not just the rowid, because that is what FTS5's
385
+ # `'delete'` command needs: it re-derives the postings to remove from
386
+ # the values it is given, and three empty strings therefore remove
387
+ # *nothing*. SQLite reports no error for it. Every posting of every
388
+ # re-indexed symbol stayed, and `symbols.id` has no `AUTOINCREMENT` —
389
+ # so the next insert took the dead row's id and the stale posting
390
+ # started pointing at a different symbol. `search` joins the rowid
391
+ # back to `symbols`, so the old name returned the new symbol: worse
392
+ # than a miss, because it reads like an answer.
393
+ #
394
+ # Both tuples come from `_fts_values`, so the agreement the paragraph
395
+ # above depends on is structural rather than a comment asking a
396
+ # reader to check two expressions in two methods.
397
+ stale = [
398
+ _fts_values(row["id"], row["name"], row["doc"], row["path"])
399
+ for row in connection.execute(
400
+ "SELECT id, name, doc, path FROM symbols WHERE path = ?", (path,)
401
+ )
370
402
  ]
371
403
  connection.executemany(
372
404
  "INSERT INTO symbols_fts(symbols_fts, rowid, name, doc, path) "
373
- "VALUES ('delete', ?, '', '', '')",
374
- [(one,) for one in ids],
405
+ "VALUES ('delete', ?, ?, ?, ?)",
406
+ stale,
375
407
  )
376
408
  connection.execute("DELETE FROM symbols WHERE path = ?", (path,))
377
409
  connection.execute("DELETE FROM refs WHERE path = ?", (path,))
@@ -420,7 +452,7 @@ class CodeGraphStore:
420
452
  by_line[definition.start_line] = identifier
421
453
  connection.execute(
422
454
  "INSERT INTO symbols_fts(rowid, name, doc, path) VALUES (?, ?, ?, ?)",
423
- (identifier, definition.name, definition.doc or "", path),
455
+ _fts_values(identifier, definition.name, definition.doc, path),
424
456
  )
425
457
  # One `{line: tightest definition}` table for the whole file, rather
426
458
  # than a scan of every definition per reference: that was
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ph-code-graph
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: pH plugin: `code_index` and `code_graph`, a Python-native tree-sitter code graph with line-accurate answers.
5
5
  Project-URL: Homepage, https://github.com/chastabor/pH
6
6
  Project-URL: Repository, https://github.com/chastabor/pH
@@ -21,7 +21,7 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
21
  Classifier: Topic :: Software Development
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: ph-core==0.2.0
24
+ Requires-Dist: ph-core==0.4.0
25
25
  Requires-Dist: tree-sitter-language-pack>=1.16
26
26
  Requires-Dist: tree-sitter>=0.25
27
27
  Description-Content-Type: text/markdown
@@ -0,0 +1,11 @@
1
+ ph_code_graph/__init__.py,sha256=Cw9l34LPyR7LjinpIIZfjrWn0i8cw8IXwdSpGTJHgVE,38079
2
+ ph_code_graph/_extract.py,sha256=9AprYeNCqIQRZOv5RWtxTQFbU6jlSlPo7ot2fkAcavg,24020
3
+ ph_code_graph/_store.py,sha256=zcxMXE6_DM5DWggQ2iMD_-ds3DfPq0oz4t0O55hyhUM,28785
4
+ ph_code_graph/bundle.yaml,sha256=x3UoJbgQ6QUootM-M6rWZpDQT1sRGSCrtiBh2LFq1ms,1190
5
+ ph_code_graph/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ ph_code_graph/skills/code-graph/SKILL.md,sha256=VNIs0a0U2iveu60E1tQHxz_nRRHOUUHmg9b5_saOMNs,2950
7
+ ph_code_graph-0.4.0.dist-info/METADATA,sha256=Y7RyUptibqyGGbMByzIU4NEhpsMGSg2XS5jxigREeP8,13501
8
+ ph_code_graph-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ ph_code_graph-0.4.0.dist-info/entry_points.txt,sha256=4DPMS_RTRniZPXb59juSl5mpTpi0fHpwViZPCvffAgo,94
10
+ ph_code_graph-0.4.0.dist-info/licenses/LICENSE,sha256=sN3yhtK25eQpVcewQbaq1k4A-_fWl3RkTf3QfGDh3ko,1070
11
+ ph_code_graph-0.4.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.0
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,11 +0,0 @@
1
- ph_code_graph/__init__.py,sha256=SbIRwgUpurM8tGGYS7VYK2HQo4LcDGGaNR5otTXMD-s,35101
2
- ph_code_graph/_extract.py,sha256=3zBEy3EibEMy4636w3o68lcwNb_wnXXWxXemuhO5WiY,23755
3
- ph_code_graph/_store.py,sha256=5ZPXvlW_Vt1U-c0ag6fgYBaDp2PVrzPGTI_aPcOFOuU,26964
4
- ph_code_graph/bundle.yaml,sha256=x3UoJbgQ6QUootM-M6rWZpDQT1sRGSCrtiBh2LFq1ms,1190
5
- ph_code_graph/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- ph_code_graph/skills/code-graph/SKILL.md,sha256=VNIs0a0U2iveu60E1tQHxz_nRRHOUUHmg9b5_saOMNs,2950
7
- ph_code_graph-0.2.0.dist-info/METADATA,sha256=xfEy_jtMardrhV8IRKIzo5jz6t9P9_Ua86sFoFZPhi0,13501
8
- ph_code_graph-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
- ph_code_graph-0.2.0.dist-info/entry_points.txt,sha256=4DPMS_RTRniZPXb59juSl5mpTpi0fHpwViZPCvffAgo,94
10
- ph_code_graph-0.2.0.dist-info/licenses/LICENSE,sha256=sN3yhtK25eQpVcewQbaq1k4A-_fWl3RkTf3QfGDh3ko,1070
11
- ph_code_graph-0.2.0.dist-info/RECORD,,