From 855f0f25806ad7c575d0d0fe7d0ab69dd5faa3f5 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 9 Sep 2026 21:55:40 +0200 Subject: [PATCH 1/2] Fix current-context symbol and import validation (ticket-006) --- docs/README.md | 2 + docs/information/current-code-references.md | 81 +++++++++++++++++++++ project/ticket-006/README.md | 13 ++++ project/ticket-006/intent.json | 27 +++++++ src/docval/validators/crossref.py | 54 +++++++------- tests/test_crossref.py | 36 +++++++++ 6 files changed, 187 insertions(+), 26 deletions(-) create mode 100644 docs/information/current-code-references.md create mode 100644 project/ticket-006/README.md create mode 100644 project/ticket-006/intent.json diff --git a/docs/README.md b/docs/README.md index 54b94c9..312d900 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/information/current-code-references.md b/docs/information/current-code-references.md new file mode 100644 index 0000000..5b035d1 --- /dev/null +++ b/docs/information/current-code-references.md @@ -0,0 +1,81 @@ +--- +{ + "schema": "wellmanifest.docs/document/v1", + "id": "current-code-references", + "kind": "information", + "version": 1, + "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": "1debd415c95765c05ec9c975fdc1626943c05e1f", + "affected_repositories": [ + "semcod/docval" + ], + "evidence": [ + "https://github.com/semcod/docval/issues/6", + "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 + + +## Purpose + +Make the documented cross-reference validator use the source context for the +current scan and identify missing internal modules in conventional Python layouts. + + +## Scope + +The correction owns `CrossRefValidator` and regression tests. Context extraction, +LLM behavior and automatic fix/export operations are unchanged. + + +## 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. + + +## 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; 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, and external imports. The documented promise of current-code +validation predates this correction; the implementation is repaired rather than +weakening that promise in README. + + +## Limits + +This is source-based checking, not proof of all runtime imports. Generated, +compiled, dynamically registered or unscanned modules may need additional +context. A diagnostic alone does not authorize rewriting either code or docs. + + +## Maintenance + +Keep freshness and full-module matching in the regression suite. Changes to +source discovery require their own coverage evidence and review. diff --git a/project/ticket-006/README.md b/project/ticket-006/README.md new file mode 100644 index 0000000..4ce1841 --- /dev/null +++ b/project/ticket-006/README.md @@ -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. diff --git a/project/ticket-006/intent.json b/project/ticket-006/intent.json new file mode 100644 index 0000000..69b9501 --- /dev/null +++ b/project/ticket-006/intent.json @@ -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 +} diff --git a/src/docval/validators/crossref.py b/src/docval/validators/crossref.py index 1af28ff..379254c 100644 --- a/src/docval/validators/crossref.py +++ b/src/docval/validators/crossref.py @@ -18,26 +18,36 @@ ) -# 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] + 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 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 _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()) @@ -124,21 +134,13 @@ 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: + 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.""" diff --git a/tests/test_crossref.py b/tests/test_crossref.py index 95b7ef4..855cf52 100644 --- a/tests/test_crossref.py +++ b/tests/test_crossref.py @@ -77,6 +77,42 @@ 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("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): From c2bb455730d5065648812b3757af939d885bec4d Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 9 Sep 2026 22:09:11 +0200 Subject: [PATCH 2/2] fix: verify unindexed import sources within project root --- docs/information/current-code-references.md | 30 +++++++++++----- src/docval/validators/crossref.py | 34 ++++++++++++++++-- tests/test_crossref.py | 38 +++++++++++++++++++++ 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/docs/information/current-code-references.md b/docs/information/current-code-references.md index 5b035d1..fe6e4d0 100644 --- a/docs/information/current-code-references.md +++ b/docs/information/current-code-references.md @@ -3,19 +3,20 @@ "schema": "wellmanifest.docs/document/v1", "id": "current-code-references", "kind": "information", - "version": 1, + "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": "1debd415c95765c05ec9c975fdc1626943c05e1f", + "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" ] @@ -53,26 +54,37 @@ 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. + ## 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; it does not -import or execute the candidate project. +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, and external imports. The documented promise of current-code -validation predates this correction; the implementation is repaired rather than -weakening that promise in README. +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. ## Limits This is source-based checking, not proof of all runtime imports. Generated, -compiled, dynamically registered or unscanned modules may need additional -context. A diagnostic alone does not authorize rewriting either code or docs. +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. ## Maintenance diff --git a/src/docval/validators/crossref.py b/src/docval/validators/crossref.py index 379254c..ce2c325 100644 --- a/src/docval/validators/crossref.py +++ b/src/docval/validators/crossref.py @@ -31,12 +31,12 @@ def __init__(self, ctx: ProjectContext): 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] - src_layout = "src/__init__.py" not in paths + 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 src_layout and module.startswith("src."): + if self._src_layout and module.startswith("src."): module = module[4:] module = module.removesuffix(".__init__") parts = module.split(".") @@ -44,6 +44,30 @@ def _build_module_set(self) -> set[str]: 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() @@ -134,7 +158,11 @@ def _check_import_paths(self, chunk: DocChunk): # Check if this is a project-internal import root_package = module.split(".")[0] - if root_package in self._internal_roots and module not in self._known_modules: + 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, diff --git a/tests/test_crossref.py b/tests/test_crossref.py index 855cf52..fb8aff5 100644 --- a/tests/test_crossref.py +++ b/tests/test_crossref.py @@ -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 @@ -101,6 +102,43 @@ def test_actual_src_package_keeps_its_import_name(self, tmp_path): 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):