Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,5 @@ Follow the [repository instructions](../AGENTS.md). New final reports belong in
- [Dependency policy](dependencies.md)

The generated content above retains its historical format; the new documentation profile is not retroactively claimed for it.

- [Current source references](information/current-code-references.md) — per-scan symbols and Python import layouts.
93 changes: 93 additions & 0 deletions docs/information/current-code-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
{
"schema": "wellmanifest.docs/document/v1",
"id": "current-code-references",
"kind": "information",
"version": 2,
"title": "Current source references in Docval",
"status": "proposed",
"owner": "semcod/docval",
"created": "2026-09-09",
"updated": "2026-09-09",
"review_after": "2026-10-09",
"source_revision": "855f0f25806ad7c575d0d0fe7d0ab69dd5faa3f5",
"affected_repositories": [
"semcod/docval"
],
"evidence": [
"https://github.com/semcod/docval/issues/6",
"https://github.com/semcod/docval/blob/855f0f25806ad7c575d0d0fe7d0ab69dd5faa3f5/docs/information/current-code-references.md",
"https://github.com/semcod/docval/blob/1debd415c95765c05ec9c975fdc1626943c05e1f/README.md",
"https://github.com/semcod/docval/blob/1debd415c95765c05ec9c975fdc1626943c05e1f/src/docval/validators/crossref.py"
]
}
---

# Current source references in Docval

<!-- docs:section purpose -->
## Purpose

Make the documented cross-reference validator use the source context for the
current scan and identify missing internal modules in conventional Python layouts.

<!-- docs:section scope -->
## Scope

The correction owns `CrossRefValidator` and regression tests. Context extraction,
LLM behavior and automatic fix/export operations are unchanged.

<!-- docs:section evidence -->
## Observed discrepancies

The prior process-global cache keyed symbols by root and class/function counts.
Renaming two classes without changing their count left the old symbols accepted
and the new symbols reported as unknown. Dependencies, modules and commands were
not in that cache identity either. A fresh validator now builds its own symbol
set from the supplied context.

The old import check recognized a package only when a source path began with
its name. Consequently `src/example/api.py` did not establish `example` as
internal, and `from example.deleted import Client` passed without a finding.
Matching any known name in a dotted path could also hide a missing module.
The checker now compares full canonical module names, resolves conventional
`src/` layouts and retains namespace parents. An actual `src/__init__.py` keeps
`src` as the package name. External package roots remain outside this check.

Independent review also reproduced a false error in the initial correction:
context collection defaults to depth four, so an existing deeper module was
absent from the known-module set. Before reporting a missing module, the checker
now probes its Python source file or namespace directory in the project root
and, for a conventional source layout, `src/`. Resolved paths must remain within
the project root; symlinks outside that boundary are not accepted as evidence.

<!-- docs:section content -->
## Validation contract

For each scan, construct a new validator from its `ProjectContext`. Repeated
scans must not reuse symbol identities merely because counts match. Import
validation checks module presence in the supplied source context and uses bounded
filesystem probes for absent entries; it does not import or execute the candidate
project.

Regressions cover unchanged counts across six symbol categories, renamed and
removed symbols, flat/src/Windows-style paths, namespace packages, a real package
named src, external imports, modules beyond the context depth, missing deep
modules, and file/directory symlinks outside the project. The documented promise
of current-code validation predates this correction; the implementation is
repaired rather than weakening that promise in README.

<!-- docs:section limitations -->
## Limits

This is source-based checking, not proof of all runtime imports. Generated,
compiled, dynamically registered or custom-layout modules may need additional
context. Source probes cover conventional Python files and namespace directories;
they do not establish runtime importability or export availability. A diagnostic
alone does not authorize rewriting either code or docs.

<!-- docs:section next_actions -->
## Maintenance

Keep freshness and full-module matching in the regression suite. Changes to
source discovery require their own coverage evidence and review.
13 changes: 13 additions & 0 deletions project/ticket-006/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# ticket-006: Intent and code alignment

- **Status**: IN_PROGRESS
- **Workflow state**: EDIT
- **Workstream**: application

SESSION_EXECUTION_AUTHORIZATION: user requested detection and repair of intent/code drift using subactor/search.

Allocation: https://github.com/semcod/docval/issues/6

AC-01: Bind discrepancies to current source and exact historical revisions.
AC-02: Repair confirmed stale behavior or documentation without changing unrelated work.
AC-03: Validate the change and distinguish local checks from protected publication.
27 changes: 27 additions & 0 deletions project/ticket-006/intent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"schema": "new-project.intent/v3",
"ticket": "ticket-006",
"summary": "SESSION_EXECUTION_AUTHORIZATION: inspect Search-backed intent drift and repair the evidenced stale side",
"workstream": "application",
"classification": {
"kind": "SERVICE",
"priority": "P2",
"origin": "health"
},
"allowedPaths": [
"src/docval/validators/crossref.py",
"tests/test_crossref.py",
"docs/information/current-code-references.md",
"docs/README.md",
"project/ticket-006/**"
],
"forbiddenPaths": [
".env",
"secrets/**",
"project/ticket-*/user-*.md"
],
"stacks": [],
"dependsOn": [],
"conflictsWith": [],
"integrationTicket": null
}
82 changes: 56 additions & 26 deletions src/docval/validators/crossref.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,60 @@
)


# Cache for symbol sets across validator instances
_symbol_cache: dict[str, set[str]] = {}


class CrossRefValidator:
"""Validate documentation references against actual project code."""

def __init__(self, ctx: ProjectContext):
self.ctx = ctx
# Use cache key based on context hash to avoid rebuilding symbol sets
cache_key = f"{ctx.root}:{len(ctx.classes)}:{len(ctx.functions)}"
if cache_key in _symbol_cache:
self._known_symbols = _symbol_cache[cache_key]
else:
self._known_symbols = self._build_symbol_set()
_symbol_cache[cache_key] = self._known_symbols
# A scan owns its context. Equal symbol counts do not identify equal code.
self._known_modules = self._build_module_set()
self._internal_roots = {name.split(".")[0] for name in self._known_modules}
self._known_symbols = self._build_symbol_set()

def _build_module_set(self) -> set[str]:
"""Resolve flat/src Python layouts, including package namespaces."""
paths = [path.replace("\\", "/") for path in self.ctx.src_files]
self._src_layout = "src/__init__.py" not in paths
modules = set(self.ctx.modules)
modules.update(path[:-3].replace("/", ".") for path in paths if path.endswith(".py"))
result: set[str] = set()
for module in modules:
if self._src_layout and module.startswith("src."):
module = module[4:]
module = module.removesuffix(".__init__")
parts = module.split(".")
if all(part.isidentifier() for part in parts):
result.update(".".join(parts[:length]) for length in range(1, len(parts) + 1))
return result

def _module_source_exists(self, module: str) -> bool:
"""Probe an unindexed module without importing it or leaving the root."""
parts = module.split(".")
if not all(part.isidentifier() for part in parts):
return False
try:
root = self.ctx.root.resolve()
except (OSError, RuntimeError):
return False
source_roots = [root, root / "src"] if self._src_layout else [root]
for source_root in source_roots:
path = source_root.joinpath(*parts)
for candidate, is_package in ((path.with_suffix(".py"), False), (path, True)):
try:
resolved = candidate.resolve()
if not resolved.is_relative_to(root):
continue
exists = resolved.is_dir() if is_package else resolved.is_file()
if exists:
return True
except (OSError, RuntimeError):
continue
return False

def _build_symbol_set(self) -> set[str]:
"""Build a set of all known code symbols (lowercase for matching)."""
symbols: set[str] = set()
symbols.update(module.lower() for module in self._known_modules)

for name in self.ctx.classes:
symbols.add(name.lower())
Expand Down Expand Up @@ -124,21 +158,17 @@ def _check_import_paths(self, chunk: DocChunk):

# Check if this is a project-internal import
root_package = module.split(".")[0]
is_internal = any(
src.startswith(root_package + "/") or src.startswith(root_package + ".")
for src in self.ctx.src_files
)

if is_internal and module.lower() not in self._known_symbols:
# Check partial match
parts = module.lower().split(".")
if not any(p in self._known_symbols for p in parts):
chunk.add_issue(
"broken_import",
Severity.ERROR,
f"Code example imports '{module}' which doesn't exist in project",
suggestion="Update the import path or remove the example",
)
if (
root_package in self._internal_roots
and module not in self._known_modules
and not self._module_source_exists(module)
):
chunk.add_issue(
"broken_import",
Severity.ERROR,
f"Code example imports '{module}' which doesn't exist in project",
suggestion="Update the import path or remove the example",
)

def _check_cli_commands(self, chunk: DocChunk):
"""Check CLI command references in code blocks."""
Expand Down
74 changes: 74 additions & 0 deletions tests/test_crossref.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest

from docval.context import build_context
from docval.models import ChunkStatus, DocChunk, DocFile, ProjectContext
from docval.validators.crossref import CrossRefValidator

Expand Down Expand Up @@ -77,6 +78,79 @@ def test_broken_import(self, ctx):
v.validate([_make_file([chunk])])
assert any(i.rule == "broken_import" for i in chunk.issues)

@pytest.mark.parametrize("prefix", ["", "src/", "src\\"])
def test_internal_module_must_match_full_path(self, tmp_path, prefix):
context = ProjectContext(
root=tmp_path,
src_files=[prefix + "example/__init__.py", prefix + "example/api/client.py"],
functions=["deleted"],
)
chunk = _make_chunk(
"```python\nfrom example.deleted import Client\n"
"from example.api import client\nfrom external.deleted import Client\n```"
)
CrossRefValidator(context).validate([_make_file([chunk])])
issues = [issue for issue in chunk.issues if issue.rule == "broken_import"]
assert len(issues) == 1
assert "example.deleted" in issues[0].message

def test_actual_src_package_keeps_its_import_name(self, tmp_path):
context = ProjectContext(root=tmp_path, src_files=["src/__init__.py", "src/api.py"])
chunk = _make_chunk("```python\nfrom src.api import Client\nfrom src.deleted import Client\n```")
CrossRefValidator(context).validate([_make_file([chunk])])
issues = [issue for issue in chunk.issues if issue.rule == "broken_import"]
assert len(issues) == 1
assert "src.deleted" in issues[0].message

@pytest.mark.parametrize("prefix", ["", "src/"])
def test_import_beyond_context_depth_uses_confined_source_probe(self, tmp_path, prefix):
package = tmp_path / prefix / "example"
module = package / "a/b/c/d/client.py"
module.parent.mkdir(parents=True)
module.write_text("class Client: pass\n")
(package / "__init__.py").write_text("")
context = build_context(tmp_path)
assert str(module.relative_to(tmp_path)) not in context.src_files
chunk = _make_chunk(
"```python\nfrom example.a.b.c.d.client import Client\n"
"from example.a.b.c.d import client\n"
"from example.a.b.c.d.deleted import Client\n```"
)
CrossRefValidator(context).validate([_make_file([chunk])])
issues = [issue for issue in chunk.issues if issue.rule == "broken_import"]
assert len(issues) == 1
assert "example.a.b.c.d.deleted" in issues[0].message

@pytest.mark.parametrize("target_kind", ["module", "namespace"])
def test_source_probe_rejects_symlinks_outside_project(self, tmp_path, target_kind):
root = tmp_path / "project"
package = root / "example"
package.mkdir(parents=True)
outside = tmp_path / "outside"
outside.mkdir()
module = outside / "client.py"
module.write_text("class Client: pass\n")
if target_kind == "module":
(package / "escaped.py").symlink_to(module)
else:
(package / "escaped").symlink_to(outside, target_is_directory=True)
context = ProjectContext(root=root, src_files=["example/__init__.py"])
chunk = _make_chunk("```python\nfrom example.escaped import Client\n```")
CrossRefValidator(context).validate([_make_file([chunk])])
assert any(issue.rule == "broken_import" for issue in chunk.issues)


@pytest.mark.parametrize("field", ["classes", "functions", "modules", "cli_commands", "endpoints", "dependencies"])
def test_rescan_uses_changed_symbols_even_with_identical_counts(tmp_path, field):
before = ProjectContext(root=tmp_path, **{field: ["OldClient", "OldParser"]})
CrossRefValidator(before)
after = ProjectContext(root=tmp_path, **{field: ["NewClient", "NewParser"]})
current = _make_chunk("Use `NewClient` and `NewParser`.")
stale = _make_chunk("Use `OldClient` and `OldParser`.")
CrossRefValidator(after).validate([_make_file([current, stale])])
assert not any(issue.rule == "orphaned_code_ref" for issue in current.issues)
assert any(issue.rule == "orphaned_code_ref" for issue in stale.issues)


class TestSkipsResolvedChunks:
def test_skips_empty(self, ctx):
Expand Down