Skip to content

Commit ea84f3c

Browse files
fix(docs): resolve wiki links repo-wide on updates and drop dotted-ref false positives
Dogfooding the self-repair loop exposed two gaps. Interlinking resolved refs only against the run page set, so on an incremental update a regenerated page lost every link to unchanged pages (433 prose refs resolved to 17 links); LinkIndex.add_prior_page_ids now widens resolution with the persisted page ids, the same pattern related_pages already used, and both passes share it. The symbol validator flagged dotted member accesses (GeneratedPage.updated_at, Page.id) that the symbol table cannot verify since dataclass fields and ORM columns are not AST symbols; 13 of 14 surviving warnings in the dogfood run were such false positives, each able to trigger a pointless repair retry. Dotted refs are now skipped; single-name hallucinations still flag.
1 parent 2681db0 commit ea84f3c

6 files changed

Lines changed: 152 additions & 31 deletions

File tree

packages/core/src/repowise/core/generation/interlinking.py

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
# Markdown code fence — content inside is verbatim source, not refs.
3535
_FENCE_RE = re.compile(r"```.*?```", re.DOTALL)
3636

37+
# Page types whose target_path is a real file path. Shared with
38+
# related_pages, which widens the same index the same way.
39+
FILE_BACKED_PAGE_TYPES = frozenset({"file_page", "api_contract", "infra_page"})
40+
3741

3842
# ---------------------------------------------------------------------------
3943
# Link index
@@ -67,17 +71,15 @@ def build(
6771
if not page.target_path:
6872
continue
6973
idx.by_target[page.target_path] = page.page_id
70-
if page.page_type in {"file_page", "api_contract", "infra_page"}:
74+
if page.page_type in FILE_BACKED_PAGE_TYPES:
7175
idx.by_path[page.target_path] = page.page_id
7276
base = Path(page.target_path).name
7377
# Don't overwrite — first wins to avoid ambiguous basename collisions.
7478
idx.by_basename.setdefault(base, page.page_id)
7579
elif page.page_type == "symbol_spotlight":
7680
# target_path is "file::name" — map the qualified form
7781
if "::" in page.target_path:
78-
idx.by_symbol_qname.setdefault(
79-
page.target_path, page.page_id
80-
)
82+
idx.by_symbol_qname.setdefault(page.target_path, page.page_id)
8183

8284
# Augment with symbol qualified_names from the parser even when
8385
# the symbol itself didn't get a spotlight — the link resolves
@@ -92,12 +94,33 @@ def build(
9294
idx.by_symbol_qname.setdefault(qname, host_page)
9395
# Last-segment match for `Foo.bar` → `bar`
9496
if "." in qname:
95-
idx.by_symbol_qname.setdefault(
96-
qname.rsplit(".", 1)[-1], host_page
97-
)
97+
idx.by_symbol_qname.setdefault(qname.rsplit(".", 1)[-1], host_page)
9898

9999
return idx
100100

101+
def add_prior_page_ids(self, prior_page_ids: Any) -> None:
102+
"""Widen resolution with page ids persisted from prior runs.
103+
104+
On an incremental update only the affected pages are in the run's
105+
page set, so a ref to any unchanged file would fail to resolve and
106+
the regenerated pages would lose their cross-page links. A page_id
107+
is ``"{page_type}:{target_path}"``, so the persisted ids carry
108+
enough to rebuild the path tables. Current-run pages always win
109+
(``setdefault``); the reader drops links whose target no longer
110+
exists, so stale prior ids are harmless. Only file-backed and
111+
symbol pages are added — other page types are not path-shaped.
112+
"""
113+
for pid in prior_page_ids or ():
114+
ptype, _, tpath = str(pid).partition(":")
115+
if not tpath:
116+
continue
117+
if ptype in FILE_BACKED_PAGE_TYPES:
118+
self.by_target.setdefault(tpath, str(pid))
119+
self.by_path.setdefault(tpath, str(pid))
120+
self.by_basename.setdefault(Path(tpath).name, str(pid))
121+
elif ptype == "symbol_spotlight" and "::" in tpath:
122+
self.by_symbol_qname.setdefault(tpath, str(pid))
123+
101124
def resolve(self, ref: str) -> str | None:
102125
"""Resolve a single ref to a ``page_id``; ``None`` if unmatched."""
103126
if not ref:
@@ -156,9 +179,12 @@ def resolve_wiki_links(
156179
if target in seen_targets:
157180
continue
158181
seen_targets.add(target)
159-
kind = "file" if "/" in ref or ref.endswith(
160-
(".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java")
161-
) else "symbol"
182+
kind = (
183+
"file"
184+
if "/" in ref
185+
or ref.endswith((".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java"))
186+
else "symbol"
187+
)
162188
links.append(WikiLink(anchor=ref, target_page_id=target, kind=kind))
163189
if len(links) >= max_links_per_page:
164190
break
@@ -173,17 +199,26 @@ def resolve_wiki_links(
173199
def attach_wiki_links_and_backlinks(
174200
pages: list[GeneratedPage],
175201
parsed_files: list[Any] | None = None,
202+
prior_page_ids: Any = None,
176203
) -> None:
177204
"""Populate ``metadata['wiki_links']`` and ``metadata['backlinks']``.
178205
179206
Mutates each :class:`GeneratedPage` in place. Idempotent and safe
180207
to call on a partially-populated page set — pages with no resolved
181208
refs simply get empty lists.
209+
210+
``prior_page_ids`` widens forward-link resolution to pages persisted
211+
from earlier runs (see :meth:`LinkIndex.add_prior_page_ids`); without
212+
it an incremental update can only link within its own diff. Backlinks
213+
are still built for current-run pages only — a prior page's stored
214+
backlinks are not reachable from here and refresh on its next
215+
regeneration.
182216
"""
183217
if not pages:
184218
return
185219

186220
index = LinkIndex.build(pages, parsed_files)
221+
index.add_prior_page_ids(prior_page_ids)
187222
by_id: dict[str, GeneratedPage] = {p.page_id: p for p in pages}
188223

189224
# First pass — resolve forward links per page.
@@ -235,12 +270,8 @@ def attach_wiki_links_and_backlinks(
235270
log.info(
236271
"wiki_links.resolved",
237272
pages=len(pages),
238-
total_forward_links=sum(
239-
len(p.metadata.get("wiki_links") or []) for p in pages
240-
),
241-
pages_with_backlinks=sum(
242-
1 for p in pages if p.metadata.get("backlinks")
243-
),
273+
total_forward_links=sum(len(p.metadata.get("wiki_links") or []) for p in pages),
274+
pages_with_backlinks=sum(1 for p in pages if p.metadata.get("backlinks")),
244275
)
245276

246277

packages/core/src/repowise/core/generation/page_generator/orchestrate.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,13 @@ async def execute(self) -> list[GeneratedPage]:
542542
try:
543543
from ..interlinking import attach_wiki_links_and_backlinks
544544

545-
attach_wiki_links_and_backlinks(all_pages, self.parsed_files)
545+
attach_wiki_links_and_backlinks(
546+
all_pages,
547+
self.parsed_files,
548+
# On incremental updates only the affected pages are in
549+
# all_pages; the persisted ids keep resolution repo-wide.
550+
prior_page_ids=list(self.gen._prior_pages or {}),
551+
)
546552
except Exception as exc:
547553
log.debug("interlinking.failed", error=str(exc))
548554

packages/core/src/repowise/core/generation/page_generator/validation.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,7 @@ def _validate_symbol_references(
201201
# (catches Click command names, decorator arguments, dict keys, etc.)
202202
# The source is in the context, but we only have the parsed file here.
203203
# Use docstring and symbol names as a cheap approximation.
204-
if (
205-
hasattr(parsed, "file_info")
206-
and hasattr(parsed.file_info, "path")
207-
and parsed.docstring
208-
):
204+
if hasattr(parsed, "file_info") and hasattr(parsed.file_info, "path") and parsed.docstring:
209205
known.update(w for w in parsed.docstring.split() if w.isidentifier())
210206

211207
warnings: list[str] = []
@@ -221,9 +217,17 @@ def _validate_symbol_references(
221217
# Skip all-uppercase (likely constants from other files: `MAX_RETRIES`)
222218
if ref.isupper():
223219
continue
220+
# Skip dotted refs entirely: they are member/attribute accesses
221+
# (`GeneratedPage.updated_at`, `config.coverage_pct`) or qualified
222+
# module paths. Dataclass fields, ORM columns, and cross-file
223+
# attribute chains are not AST symbols, so they cannot be verified
224+
# here and were the dominant false-positive source in dogfooding.
225+
# Whole-name hallucinations — the high-signal case — are
226+
# single-segment and still flagged below.
227+
if "." in ref:
228+
continue
224229
# Check against known names
225-
base = ref.split(".")[-1]
226-
if ref in known or base in known:
230+
if ref in known:
227231
continue
228232
# Skip if the ref is a substring of any known symbol (covers partial
229233
# references like `parse` when `parse_file` exists)

packages/core/src/repowise/core/generation/related_pages.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import structlog
2020

21+
from .interlinking import FILE_BACKED_PAGE_TYPES as _FILE_BACKED
2122
from .interlinking import LinkIndex
2223
from .models import GeneratedPage
2324

@@ -30,9 +31,6 @@
3031
_PER_REASON_CAP = 5
3132
_TOTAL_CAP = 12
3233

33-
# Page types whose target_path is a real file path.
34-
_FILE_BACKED = frozenset({"file_page", "api_contract", "infra_page"})
35-
3634

3735
def _co_change_partners(git_meta: dict | None) -> list[tuple[str, float]]:
3836
"""``(partner_path, co_change_count)`` pairs, strongest first."""
@@ -93,12 +91,12 @@ def attach_related_pages(
9391
return
9492

9593
index = LinkIndex.build(pages)
94+
index.add_prior_page_ids(prior_page_ids)
9695
titles = {p.page_id: p.title for p in pages}
9796
for pid in prior_page_ids or ():
98-
ptype, _, tpath = str(pid).partition(":")
99-
if ptype in _FILE_BACKED and tpath:
100-
index.by_path.setdefault(tpath, pid)
101-
titles.setdefault(pid, tpath)
97+
_, _, tpath = str(pid).partition(":")
98+
if tpath:
99+
titles.setdefault(str(pid), tpath)
102100
pr = pagerank or {}
103101

104102
# Adjacency from the import graph: src imports dst.

tests/unit/generation/test_interlinking.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,46 @@ def test_index_resolves_basename_when_unique():
122122
)
123123

124124

125+
def test_prior_page_ids_widen_forward_resolution():
126+
"""On an incremental update, refs to pages outside the run's subset
127+
resolve via the persisted prior ids; backlinks stay run-local."""
128+
pages = [
129+
_make_page(
130+
"file_page",
131+
"src/app/main.py",
132+
content="Delegates to `src/lib/utils.py` and reuses `helpers.py`.",
133+
),
134+
]
135+
136+
attach_wiki_links_and_backlinks(
137+
pages,
138+
prior_page_ids=[
139+
"file_page:src/lib/utils.py",
140+
"file_page:src/lib/helpers.py",
141+
"module_page:community-3", # not path-shaped, ignored
142+
],
143+
)
144+
145+
targets = {link["target_page_id"] for link in pages[0].metadata["wiki_links"]}
146+
assert targets == {"file_page:src/lib/utils.py", "file_page:src/lib/helpers.py"}
147+
148+
149+
def test_current_run_pages_win_over_prior_ids():
150+
"""A page regenerated this run resolves to itself, not a stale prior id."""
151+
pages = [
152+
_make_page("file_page", "a.py", content="Uses `b.py`."),
153+
_make_page("file_page", "b.py", content=""),
154+
]
155+
156+
attach_wiki_links_and_backlinks(pages, prior_page_ids=["file_page:b.py"])
157+
158+
assert pages[0].metadata["wiki_links"] == [
159+
{"anchor": "b.py", "target_page_id": "file_page:b.py", "kind": "file"}
160+
]
161+
# b.py is in the run, so its backlink from a.py is materialized.
162+
assert pages[1].metadata["backlinks"][0]["source_page_id"] == "file_page:a.py"
163+
164+
125165
def test_link_index_build_returns_expected_keys():
126166
pages = [
127167
_make_page("file_page", "src/x.py"),
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Unit tests for the symbol-reference hallucination validator."""
2+
3+
from __future__ import annotations
4+
5+
from repowise.core.generation.page_generator.validation import _validate_symbol_references
6+
from repowise.core.ingestion.models import ParsedFile
7+
8+
from .conftest import _make_file_info, _make_symbol
9+
10+
11+
def _parsed() -> ParsedFile:
12+
return ParsedFile(
13+
file_info=_make_file_info(path="pkg/mod.py"),
14+
symbols=[_make_symbol(name="Thing", file_path="pkg/mod.py")],
15+
imports=[],
16+
exports=["Thing"],
17+
docstring="mod",
18+
parse_errors=[],
19+
content_hash="h",
20+
)
21+
22+
23+
def test_flags_unknown_single_name() -> None:
24+
warns = _validate_symbol_references("Uses `PhantomThing` heavily.", _parsed())
25+
assert warns == ["PhantomThing"]
26+
27+
28+
def test_known_symbol_and_export_pass() -> None:
29+
assert _validate_symbol_references("`Thing` is exported.", _parsed()) == []
30+
31+
32+
def test_dotted_member_access_is_never_flagged() -> None:
33+
"""Attribute chains can't be verified from the symbol table (dataclass
34+
fields and ORM columns are not symbols), so dotted refs are skipped even
35+
when no segment is known."""
36+
content = "`Thing.updated_at` and `config.coverage_pct` and `Fake.member` too."
37+
assert _validate_symbol_references(content, _parsed()) == []
38+
39+
40+
def test_paths_commands_and_short_refs_pass() -> None:
41+
content = "See `pkg/mod.py`, run `repowise-update`, index `db` by `x`."
42+
assert _validate_symbol_references(content, _parsed()) == []

0 commit comments

Comments
 (0)