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.
- {ph_code_graph-0.2.0 → ph_code_graph-0.3.0}/PKG-INFO +2 -2
- {ph_code_graph-0.2.0 → ph_code_graph-0.3.0}/pyproject.toml +2 -2
- {ph_code_graph-0.2.0 → ph_code_graph-0.3.0}/src/ph_code_graph/__init__.py +45 -0
- {ph_code_graph-0.2.0 → ph_code_graph-0.3.0}/src/ph_code_graph/_store.py +40 -8
- {ph_code_graph-0.2.0 → ph_code_graph-0.3.0}/tests/test_code_graph.py +155 -0
- {ph_code_graph-0.2.0 → ph_code_graph-0.3.0}/.gitignore +0 -0
- {ph_code_graph-0.2.0 → ph_code_graph-0.3.0}/LICENSE +0 -0
- {ph_code_graph-0.2.0 → ph_code_graph-0.3.0}/README.md +0 -0
- {ph_code_graph-0.2.0 → ph_code_graph-0.3.0}/src/ph_code_graph/_extract.py +0 -0
- {ph_code_graph-0.2.0 → ph_code_graph-0.3.0}/src/ph_code_graph/bundle.yaml +0 -0
- {ph_code_graph-0.2.0 → ph_code_graph-0.3.0}/src/ph_code_graph/py.typed +0 -0
- {ph_code_graph-0.2.0 → ph_code_graph-0.3.0}/src/ph_code_graph/skills/code-graph/SKILL.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ph-code-graph
|
|
3
|
-
Version: 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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
-
#
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|