ph-code-graph 0.2.0__tar.gz → 0.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ph-code-graph
3
- Version: 0.2.0
3
+ Version: 0.3.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.3.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ph-code-graph"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "pH plugin: `code_index` and `code_graph`, a Python-native tree-sitter code graph with line-accurate answers."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -29,7 +29,7 @@ classifiers = [
29
29
  # in (371 available, the rest fetched on demand — which this row never does
30
30
  # implicitly; see `_extract.indexable`). No Rust toolchain, no Node, no
31
31
  # submodule: the graph itself is stdlib `sqlite3`.
32
- dependencies = ["ph-core==0.2.0", "tree-sitter>=0.25", "tree-sitter-language-pack>=1.16"]
32
+ dependencies = ["ph-core==0.3.0", "tree-sitter>=0.25", "tree-sitter-language-pack>=1.16"]
33
33
 
34
34
  [project.urls]
35
35
  Homepage = "https://github.com/chastabor/pH"
@@ -43,6 +43,7 @@ from __future__ import annotations
43
43
 
44
44
  import hashlib
45
45
  import logging
46
+ from collections.abc import Mapping
46
47
  from dataclasses import dataclass, field
47
48
  from pathlib import Path
48
49
  from typing import Any, Literal
@@ -59,6 +60,7 @@ from ph.seams._registry import contribute_item
59
60
  from ph.seams.changes import tree_state
60
61
  from ph.seams.commands import CommandContext, CommandDefinition
61
62
  from ph.seams.diagnostics import Diagnostic, contribute
63
+ from ph.seams.fs import FsService
62
64
  from ph.seams.skills import discover_skills
63
65
  from ph.text import count_of
64
66
  from ph.tools.definition import ToolModel, ToolOutput, ToolRunContext, define_tool, text_content
@@ -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.
@@ -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
@@ -539,6 +539,48 @@ async def test_an_edit_is_picked_up_and_replaces_the_old_rows(
539
539
  assert here.value["symbols"], "the new symbol was not indexed"
540
540
 
541
541
 
542
+ async def test_a_renamed_symbol_is_not_found_under_its_old_name(
543
+ mount: MountProfile, tmp_path: Path
544
+ ) -> None:
545
+ """The full-text index has to forget too, and it was not (X1).
546
+
547
+ `symbols_fts` is contentless, and a contentless FTS5 table cannot be asked
548
+ which postings belong to a row — the `'delete'` command needs the column
549
+ values the row was *inserted* with, and it was being handed three empty
550
+ strings. SQLite takes that quietly and removes nothing, so every posting for
551
+ every symbol ever re-indexed stayed in the index.
552
+
553
+ Stale postings would be merely wasteful if the rowids were retired; they are
554
+ not. `symbols.id` has no `AUTOINCREMENT`, so the next insert reuses the id
555
+ the deleted symbol had, and the old posting now points at a *different*
556
+ symbol. `search` joins the FTS rowid back to `symbols`, so asking for the old
557
+ name returns the new symbol — which is worse than a miss, because it reads
558
+ like an answer.
559
+
560
+ `define` was already covered by the sibling test above and passes either way:
561
+ it queries `symbols` directly and never touches the FTS table. `search` is
562
+ the mode that goes through it, which is why the defect survived.
563
+ """
564
+ # **One file, and that is load-bearing.** `symbols.id` is reused only when
565
+ # the deleted row held the highest one; with a second file indexed after it,
566
+ # the stale posting points at an id nothing has taken back and the join in
567
+ # `search` drops it, hiding the defect behind a miss.
568
+ _tree(tmp_path)
569
+ ctx = await _indexed(mount, tmp_path)
570
+ agent = _agent(ctx)
571
+ await run_tool(ctx, "code_index", {"paths": ["pkg/helpers.py"]}, agent=agent)
572
+
573
+ (tmp_path / "pkg" / "helpers.py").write_text(
574
+ "def gamma(value):\n return value\n", encoding="utf-8"
575
+ )
576
+ await run_tool(ctx, "code_index", {"paths": ["pkg/helpers.py"]}, agent=agent)
577
+
578
+ stale = await run_tool(ctx, "code_graph", {"mode": "search", "query": "shared"}, agent=agent)
579
+ assert stale.value["symbols"] == [], "the old name still has postings in the index"
580
+ found = await run_tool(ctx, "code_graph", {"mode": "search", "query": "gamma"}, agent=agent)
581
+ assert [one["name"] for one in found.value["symbols"]] == ["gamma"]
582
+
583
+
542
584
  async def test_forget_removes_a_path_from_the_index(mount: MountProfile, tmp_path: Path) -> None:
543
585
  _tree(tmp_path)
544
586
  ctx = await _indexed(mount, tmp_path)
@@ -1191,3 +1233,116 @@ async def test_a_tree_with_no_version_control_still_indexes(
1191
1233
  assert first.value["indexed"] == 2, "the fixture's two modules"
1192
1234
  # Still reported unchanged — by the content hash, which never went away.
1193
1235
  assert again.value["indexed"] == 0 and again.value["unchanged"] == 2
1236
+
1237
+
1238
+ async def test_a_deleted_file_leaves_the_index_on_the_next_sweep(
1239
+ mount: MountProfile, tmp_path: Path
1240
+ ) -> None:
1241
+ """X3 — the walk never diffed itself against what the index already held.
1242
+
1243
+ A file removed from the tree kept its symbols until somebody ran `forget` by
1244
+ hand, so `code_graph` went on naming definitions at paths that are not there
1245
+ — and a *rename* showed the symbol twice, once under each name, with nothing
1246
+ saying which was real. A pointer into a file that does not exist is the one
1247
+ answer this tool must not give, because the model's next move is to open it.
1248
+
1249
+ Only a full sweep does this, which is the second half: narrowing the walk
1250
+ must not empty the index, or `code_index paths=["src/x.py"]` would delete
1251
+ everything else.
1252
+ """
1253
+ _tree(tmp_path)
1254
+ ctx = await _indexed(mount, tmp_path)
1255
+ agent = _agent(ctx)
1256
+ await run_tool(ctx, "code_index", {"paths": ["."]}, agent=agent)
1257
+
1258
+ (tmp_path / "pkg" / "helpers.py").unlink()
1259
+ swept = await run_tool(ctx, "code_index", {"paths": ["."]}, agent=agent)
1260
+
1261
+ assert not swept.is_error, text_of(swept.content)
1262
+ assert swept.value["removed"] == 1, "the deleted file kept its symbols"
1263
+ found = await run_tool(ctx, "code_graph", {"mode": "define", "query": "shared"}, agent=agent)
1264
+ assert found.value["symbols"] == [], "a definition still points at a file that is gone"
1265
+
1266
+
1267
+ async def test_a_walk_that_did_not_reach_a_file_does_not_forget_it(
1268
+ mount: MountProfile, tmp_path: Path
1269
+ ) -> None:
1270
+ """X3's guard rail, and the reason the sweep asks the filesystem.
1271
+
1272
+ The first cut of this fix differenced the index against the *walk* and
1273
+ decided it was a full sweep by pattern-matching the arguments
1274
+ (`paths == ["."]`). Two things make that wrong, and both empty the index
1275
+ rather than trimming it: `fs.collect` stops at `max_files` and reports
1276
+ nothing, so on a tree past the cap the walk is a prefix of the scope; and
1277
+ the `permissions-fs` screen prunes whole directories, so a rule hiding a
1278
+ subtree makes those files "absent". Asking `exists()` settles all three —
1279
+ truncated, pruned and narrowed — because "is this file still there" is the
1280
+ question, and it never depended on the walk at all.
1281
+ """
1282
+ _tree(tmp_path)
1283
+ ctx = await _indexed(mount, tmp_path)
1284
+ agent = _agent(ctx)
1285
+ await run_tool(ctx, "code_index", {"paths": ["."]}, agent=agent)
1286
+
1287
+ narrowed = await run_tool(ctx, "code_index", {"paths": ["pkg/helpers.py"]}, agent=agent)
1288
+
1289
+ assert narrowed.value["removed"] == 0
1290
+ still = await run_tool(ctx, "code_graph", {"mode": "define", "query": "shared"}, agent=agent)
1291
+ assert still.value["symbols"], "a narrowed walk emptied the index"
1292
+
1293
+ # And a walk truncated by `max_files`, which *looks* like a full sweep: the
1294
+ # arguments are the row's own defaults and `paths` is still only a subset.
1295
+ capped = await _indexed(mount, tmp_path / "capped", max_files=1)
1296
+ narrow_agent = _agent(capped)
1297
+ await run_tool(capped, "code_index", {"paths": ["."]}, agent=narrow_agent)
1298
+ swept = await run_tool(capped, "code_index", {"paths": ["."]}, agent=narrow_agent)
1299
+
1300
+ assert swept.value["removed"] == 0, "a truncated walk forgot the files it never reached"
1301
+
1302
+
1303
+ async def test_a_forgotten_symbols_docstring_stops_being_searchable(
1304
+ mount: MountProfile, tmp_path: Path
1305
+ ) -> None:
1306
+ """N5 — the FTS delete has to carry the values the insert used.
1307
+
1308
+ FTS5's `'delete'` re-derives the postings to remove from the values it is
1309
+ handed, so a delete that passes different values removes only the postings
1310
+ those values imply. Measured against SQLite 3.45: deleting with `""` for a
1311
+ symbol inserted with real docstring text removes the **name** posting and
1312
+ leaves every **doc** posting behind.
1313
+
1314
+ A stale posting is worse than a miss: `symbols.id` has no `AUTOINCREMENT`,
1315
+ so a later insert takes the dead row's id and the old docstring starts
1316
+ answering with a different symbol — which is what this stages, because
1317
+ `search` joins the rowid back to `symbols` and a posting whose symbol is
1318
+ simply gone is invisible.
1319
+
1320
+ Driven through `code_index`/`code_graph` rather than against `_fts_values`,
1321
+ which is how this first shipped: the helper was written, exported to a unit
1322
+ test, and never called, so the test passed while both live expressions
1323
+ stayed inline.
1324
+
1325
+ Sabotage: pass `""` as the doc at either call site and `zarquon` answers
1326
+ with the symbol that took the id.
1327
+ """
1328
+ _tree(tmp_path)
1329
+ (tmp_path / "pkg" / "old.py").write_text(
1330
+ 'def retired():\n """A zarquon of the third kind."""\n return 1\n', encoding="utf-8"
1331
+ )
1332
+ ctx = await _indexed(mount, tmp_path)
1333
+ agent = _agent(ctx)
1334
+ await run_tool(ctx, "code_index", {"paths": ["pkg"]}, agent=agent)
1335
+ seen = await run_tool(ctx, "code_graph", {"mode": "search", "query": "zarquon"}, agent=agent)
1336
+ assert [one["name"] for one in seen.value["symbols"]] == ["retired"], "never indexed"
1337
+
1338
+ # Forget it, then index a *new* symbol that recycles the dead row's id.
1339
+ (tmp_path / "pkg" / "old.py").unlink()
1340
+ await run_tool(ctx, "code_index", {"paths": ["pkg/old.py"], "forget": True}, agent=agent)
1341
+ (tmp_path / "pkg" / "new.py").write_text("def arrived():\n return 2\n", encoding="utf-8")
1342
+ await run_tool(ctx, "code_index", {"paths": ["pkg"]}, agent=agent)
1343
+
1344
+ gone = await run_tool(ctx, "code_graph", {"mode": "search", "query": "zarquon"}, agent=agent)
1345
+
1346
+ assert gone.value["symbols"] == [], (
1347
+ "a docstring posting outlived its symbol and answered for the one that took its id"
1348
+ )
File without changes
File without changes
File without changes