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

@@ -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()