Make the import-surface scan cover symlinked external plugins
All checks were successful
CI / backend (push) Successful in 23s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

Path.rglob does not descend symlinks, so a symlinked external plugin
(the ADR-003 dev loop) silently escaped the contract-purity scan. The
scanner now resolves plugin dirs before walking, a regression test
plants a symlinked plugin with a real violation and asserts it is
flagged, and the known-limitation notes in the external-repo docs are
lifted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 10:36:28 -04:00
parent 529b9f2fed
commit 81178b7be3
3 changed files with 52 additions and 14 deletions

View File

@@ -266,12 +266,13 @@ manifest parses, the `core_version` range admits the framework's
contract test run errors and the script exits nonzero), models expose
`__tablename__`, and hooks return the right shapes.
One gap to know about: the framework's `test_plugins_only_import_contract_surface`
scans `plugins/` with `Path.rglob`, which does not descend symlinked directories
on CPython 3.12. So that particular sub-test does not see a symlinked external
plugin's source. Keep an equivalent import-surface assertion in your own
`tests/` so your CI still enforces "core imports only via `shopdb.api`". A
minimal version:
The import-surface scan (`test_plugins_only_import_contract_surface`) covers
symlinked plugins too: the scanner resolves each plugin directory before
walking it, because `Path.rglob` alone does not descend symlinks (pinned by
`test_import_scan_covers_symlinked_plugins`). You can additionally keep an
equivalent import-surface assertion in your own `tests/`, so violations fail
in your repo's CI even when run without the framework harness. A minimal
version:
```python
# tests/test_import_surface.py

View File

@@ -32,12 +32,12 @@
# # Offline: test a bundled plugin against this checkout, no network
# LOCAL_FRAMEWORK=. PLUGIN_DIR=plugins/warranty scripts/test-external-plugin.sh
#
# Note on coverage: a symlinked plugin is discovered and loaded by the plugin
# loader (so loadability, manifest validity, and the core_version range are all
# checked), but the import-surface scan in test_plugin_contract.py uses
# Path.rglob over plugins/, which does not descend symlinked directories on
# CPython 3.12. To have that scan cover your plugin too, keep an equivalent
# import-surface assertion in your own tests/ (see docs/PLUGIN-EXTERNAL-REPO.md).
# Coverage: a symlinked plugin is discovered and loaded by the plugin loader
# (loadability, manifest validity, core_version range) AND scanned by the
# import-surface contract test - the scanner resolves each plugin dir before
# walking, since bare Path.rglob does not descend symlinks. Optionally keep an
# equivalent import-surface assertion in your own tests/ so violations fail
# in your repo's CI too (see docs/PLUGIN-EXTERNAL-REPO.md).
set -eu

View File

@@ -209,8 +209,17 @@ def _plugin_source_files():
# Skip migrations/ - Alembic env.py is framework glue that legitimately
# calls the shared runner in shopdb.plugins.alembic_template (see ADR-008),
# not plugin domain code. Mirrors the style check excluding versions/.
return [p for p in root.rglob('*.py')
if '__pycache__' not in p.parts and 'migrations' not in p.parts]
# Resolve each plugin dir before rglob: rglob does not descend symlinked
# directories, and external plugins arrive as symlinks per ADR-003.
files = [p for p in root.glob('*.py')]
for plugindir in sorted(root.iterdir()):
if not plugindir.is_dir():
continue
for path in plugindir.resolve().rglob('*.py'):
if '__pycache__' in path.parts or 'migrations' in path.parts:
continue
files.append(path)
return files
def test_plugins_only_import_contract_surface():
@@ -231,3 +240,31 @@ def test_plugins_only_import_contract_surface():
'Plugins must import core only via shopdb.api or shopdb.plugins.base. '
'Violations:\n' + '\n'.join(violations)
)
def test_import_scan_covers_symlinked_plugins(tmp_path):
"""External plugins symlinked into plugins/ ARE scanned for violations.
Path.rglob does not descend symlinked directories, so a naive scan would
silently skip external plugins installed per ADR-003 (clone or symlink
into plugins/<name>/). The scanner resolves each plugin dir first; this
test plants a symlinked plugin with a violating import and asserts the
scan both sees the file and flags the violation.
"""
root = Path(__file__).resolve().parent.parent / 'plugins'
external = tmp_path / 'symlinkdemo'
external.mkdir()
# violating import: internal core path, not the contract surface
(external / 'plugin.py').write_text('from shopdb.extensions import db\n')
link = root / 'zzsymlinkdemo'
link.symlink_to(external, target_is_directory=True)
try:
scanned = {str(p) for p in _plugin_source_files()}
assert any('symlinkdemo' in s for s in scanned), (
'symlinked plugin sources were not scanned'
)
with pytest.raises(AssertionError, match='shopdb.extensions'):
test_plugins_only_import_contract_surface()
finally:
link.unlink()